Update 3d chart mode, add US heatmap, move kjol-web -> kjol-website
This commit is contained in:
18
.gitignore
vendored
18
.gitignore
vendored
@@ -84,20 +84,20 @@ wwwroot/public.bundle.min.js
|
||||
internal/handlers/public_pages.gen.go
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# kjol-web (go/cmd/kjol-web) build output.
|
||||
# kjol-website (go/cmd/kjol-website) build output.
|
||||
#
|
||||
# The rules above came from the application repos and every one of them contains
|
||||
# a slash, which anchors it to the directory holding this .gitignore — the repo
|
||||
# root. So `wwwroot/bundle.min.js` matches /wwwroot/..., and NOT the identical
|
||||
# artifact under go/cmd/kjol-web/wwwroot/. These are the same rules, re-anchored.
|
||||
# artifact under go/cmd/kjol-website/wwwroot/. These are the same rules, re-anchored.
|
||||
#
|
||||
# Everything here is regenerated by `go run ./build`; none of it is authored.
|
||||
go/cmd/kjol-web/wwwroot/bundle.min.*
|
||||
go/cmd/kjol-web/wwwroot/public.bundle.min.*
|
||||
go/cmd/kjol-web/wwwroot/app.css
|
||||
go/cmd/kjol-web/wwwroot/wasm_exec.js
|
||||
go/cmd/kjol-web/wwwroot/vendor/
|
||||
go/cmd/kjol-web/frontend/src/ui/generated/
|
||||
go/cmd/kjol-web/internal/handlers/public_pages.gen.go
|
||||
go/cmd/kjol-website/wwwroot/bundle.min.*
|
||||
go/cmd/kjol-website/wwwroot/public.bundle.min.*
|
||||
go/cmd/kjol-website/wwwroot/app.css
|
||||
go/cmd/kjol-website/wwwroot/wasm_exec.js
|
||||
go/cmd/kjol-website/wwwroot/vendor/
|
||||
go/cmd/kjol-website/frontend/src/ui/generated/
|
||||
go/cmd/kjol-website/internal/handlers/public_pages.gen.go
|
||||
# kjol framework (git submodule; deny-list above would otherwise ignore it)
|
||||
!/kjol
|
||||
|
||||
40
.vscode/launch.json
vendored
40
.vscode/launch.json
vendored
@@ -1,77 +1,77 @@
|
||||
{
|
||||
// Debug configs for kjol-web (the website). cwd is the site dir because the dev server
|
||||
// Debug configs for kjol-website (the website). cwd is the site dir because the dev server
|
||||
// resolves ./wwwroot, ./app, ./wasm, ./frontend and the watched engine dirs relative
|
||||
// to it.
|
||||
//
|
||||
// Every dev-server config preLaunchTasks "kjol-web: prebuild" (codegen -> Tailwind ->
|
||||
// Every dev-server config preLaunchTasks "kjol-website: prebuild" (codegen -> Tailwind ->
|
||||
// Solid bundle). Codegen must run before the server is COMPILED — it writes
|
||||
// app/*.gen.go, which the server imports — and under the debugger the binary is built
|
||||
// by Delve, not by the server's own Build hook, so nothing else would generate it.
|
||||
//
|
||||
// GOWORK=off on every config: kjol-web is its own module (module kjolweb, replace kjol
|
||||
// GOWORK=off on every config: kjol-website is its own module (module kjolwebsite, replace kjol
|
||||
// => ../..). Inside a parent project the parent's go.work — which lists kjol/go but not
|
||||
// kjolweb — shadows it, and the build fails "main module (...) does not contain package
|
||||
// .../kjol-web/server". Off, Go uses kjolweb's own go.mod; standalone it is a no-op.
|
||||
// kjolwebsite — shadows it, and the build fails "main module (...) does not contain package
|
||||
// .../kjol-website/server". Off, Go uses kjolwebsite's own go.mod; standalone it is a no-op.
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "kjol-web: dev server",
|
||||
"name": "kjol-website: dev server",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}/go/cmd/kjol-web/server",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-web",
|
||||
"program": "${workspaceFolder}/go/cmd/kjol-website/server",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-website",
|
||||
"env": { "GOWORK": "off" },
|
||||
"args": ["-addr", ":8085"],
|
||||
"preLaunchTask": "kjol-web: prebuild"
|
||||
"preLaunchTask": "kjol-website: prebuild"
|
||||
},
|
||||
{
|
||||
"name": "kjol-web: dev server (no watch)",
|
||||
"name": "kjol-website: dev server (no watch)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}/go/cmd/kjol-web/server",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-web",
|
||||
"program": "${workspaceFolder}/go/cmd/kjol-website/server",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-website",
|
||||
"env": { "GOWORK": "off" },
|
||||
// -watch=false skips the server's own Build entirely, so wwwroot is served exactly
|
||||
// as it sits on disk. The prebuild is what puts current generated code, CSS and JS
|
||||
// bundles there; without it you would be debugging against stale artefacts — and
|
||||
// with no bundle at all, every /js/* page would come up blank.
|
||||
"args": ["-watch=false"],
|
||||
"preLaunchTask": "kjol-web: prebuild"
|
||||
"preLaunchTask": "kjol-website: prebuild"
|
||||
},
|
||||
{
|
||||
"name": "kjol-web: cold build (-build)",
|
||||
"name": "kjol-website: cold build (-build)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
// The same binary, with the flag that runs every build step once and exits. There is
|
||||
// no separate ./build command: the steps are a library, because the server imports
|
||||
// them and Go will not let you import a main.
|
||||
"program": "${workspaceFolder}/go/cmd/kjol-web/server",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-web",
|
||||
"program": "${workspaceFolder}/go/cmd/kjol-website/server",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-website",
|
||||
"env": { "GOWORK": "off" },
|
||||
"args": ["-build"]
|
||||
},
|
||||
{
|
||||
"name": "kjol-web: codegen (wasmgen)",
|
||||
"name": "kjol-website: codegen (wasmgen)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}/go/cmd/wasmgen",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-web",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-website",
|
||||
"env": { "GOWORK": "off" },
|
||||
"args": ["./app"]
|
||||
},
|
||||
{
|
||||
"name": "kjol-web: bundle (Solid)",
|
||||
"name": "kjol-website: bundle (Solid)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
// Debug the JS build itself — the Solid compiler, the Tailwind engine, the SSR bake.
|
||||
// All of it is Go, so all of it takes a breakpoint.
|
||||
"program": "${workspaceFolder}/go/cmd/bundle",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-web",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-website",
|
||||
"env": { "GOWORK": "off" },
|
||||
"args": [
|
||||
"-app", "frontend",
|
||||
|
||||
56
.vscode/tasks.json
vendored
56
.vscode/tasks.json
vendored
@@ -1,66 +1,66 @@
|
||||
{
|
||||
// Tasks for the kjol repo.
|
||||
//
|
||||
// kjol-web — the website, and the runnable example of both web layers — is a nested
|
||||
// module at go/cmd/kjol-web, so its tasks set cwd there; the module-wide Go tasks run
|
||||
// kjol-website — the website, and the runnable example of both web layers — is a nested
|
||||
// module at go/cmd/kjol-website, so its tasks set cwd there; the module-wide Go tasks run
|
||||
// in go/.
|
||||
//
|
||||
// These used to be called "gowasm: ...", which stopped being true: the site is two
|
||||
// front-ends now, and one build compiles Go to WebAssembly AND bundles a Solid app.
|
||||
// They are "kjol-web: ..." to match the directory they act on.
|
||||
// They are "kjol-website: ..." to match the directory they act on.
|
||||
//
|
||||
// The build pipeline is Go, not a shell script — see go/cmd/kjol-web/build. The dev
|
||||
// The build pipeline is Go, not a shell script — see go/cmd/kjol-website/build. The dev
|
||||
// server calls the same functions on every save, and a bash script would not run for
|
||||
// anyone on Windows. Note there is no `./build` COMMAND any more: the steps are a
|
||||
// library (the server imports them, and Go will not let you import a main), so a cold
|
||||
// build is `go run ./server -build`.
|
||||
//
|
||||
// "kjol-web: prebuild" is what every run/debug config depends on. Codegen has to happen
|
||||
// "kjol-website: prebuild" is what every run/debug config depends on. Codegen has to happen
|
||||
// before the server is COMPILED — it writes app/*.gen.go, which the server imports — and
|
||||
// under the debugger the binary is built by Delve, not by the server's own Build hook,
|
||||
// so nothing else would generate it. Tailwind and the Solid bundle are in there too, so
|
||||
// that a -watch=false session serves current artefacts instead of stale ones. Each step
|
||||
// is also exposed on its own, so you can rerun just the one you need.
|
||||
//
|
||||
// The kjol-web tasks (cwd go/cmd/kjol-web) set GOWORK=off: kjol-web is its own module
|
||||
// (module kjolweb, replace kjol => ../..), and inside a parent project the parent's
|
||||
// go.work — which does not list kjolweb — would shadow it and the go command would fail
|
||||
// "main module (...) does not contain package .../kjol-web/...". The kjol-module tasks
|
||||
// The kjol-website tasks (cwd go/cmd/kjol-website) set GOWORK=off: kjol-website is its own module
|
||||
// (module kjolwebsite, replace kjol => ../..), and inside a parent project the parent's
|
||||
// go.work — which does not list kjolwebsite — would shadow it and the go command would fail
|
||||
// "main module (...) does not contain package .../kjol-website/...". The kjol-module tasks
|
||||
// (cwd go) keep the workspace, since kjol/go is what a parent's go.work does list.
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "kjol-web: prebuild",
|
||||
"label": "kjol-website: prebuild",
|
||||
"detail": "Codegen -> Tailwind -> Solid bundle. Dependency of the run + debug configs.",
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": ["kjol-web: codegen", "kjol-web: tailwind", "kjol-web: bundle (Solid)"],
|
||||
"dependsOn": ["kjol-website: codegen", "kjol-website: tailwind", "kjol-website: bundle (Solid)"],
|
||||
"problemMatcher": [],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "kjol-web: codegen",
|
||||
"label": "kjol-website: codegen",
|
||||
"detail": "Regenerate app/*.gen.go from the //gowasm: directives (pages, layouts, server components)",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": ["run", "kjol/cmd/wasmgen", "./app"],
|
||||
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web", "env": { "GOWORK": "off" } },
|
||||
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-website", "env": { "GOWORK": "off" } },
|
||||
"problemMatcher": ["$go"],
|
||||
"presentation": { "reveal": "silent", "panel": "shared" },
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "kjol-web: tailwind",
|
||||
"label": "kjol-website: tailwind",
|
||||
"detail": "Compile css/app.css -> wwwroot/app.css, scanning the webui kit + the site's Go markup",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": [
|
||||
"run", "./cmd/twcss",
|
||||
"-entry", "cmd/kjol-web/css/app.css",
|
||||
"-out", "cmd/kjol-web/wwwroot/app.css",
|
||||
"-entry", "cmd/kjol-website/css/app.css",
|
||||
"-out", "cmd/kjol-website/wwwroot/app.css",
|
||||
"-base", ".",
|
||||
"webui/**/*.go",
|
||||
"cmd/kjol-web/app/**/*.go",
|
||||
"cmd/kjol-web/server/**/*.go"
|
||||
"cmd/kjol-website/app/**/*.go",
|
||||
"cmd/kjol-website/server/**/*.go"
|
||||
],
|
||||
// Run from the kjol module root so the Tailwind engine's deps resolve in kjol's
|
||||
// go.mod, not the site's.
|
||||
@@ -70,7 +70,7 @@
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "kjol-web: bundle (Solid)",
|
||||
"label": "kjol-website: bundle (Solid)",
|
||||
"detail": "TSX -> Solid -> esbuild, plus the SSR bake: wwwroot/bundle.min.{js,css} + public.bundle.min.*",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
@@ -83,19 +83,19 @@
|
||||
"-out", "wwwroot",
|
||||
"-gen-ts", "frontend/src/ui/generated"
|
||||
],
|
||||
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web", "env": { "GOWORK": "off" } },
|
||||
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-website", "env": { "GOWORK": "off" } },
|
||||
"problemMatcher": ["$go"],
|
||||
"presentation": { "reveal": "silent", "panel": "shared" },
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "kjol-web: dev server (hot reload)",
|
||||
"label": "kjol-website: dev server (hot reload)",
|
||||
"detail": "SSR + /rsc + hot reload on :8085. Rebuilds on save; a .css save recompiles Tailwind only and swaps the stylesheet in place.",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": ["run", "./server"],
|
||||
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web", "env": { "GOWORK": "off" } },
|
||||
"dependsOn": ["kjol-web: prebuild"],
|
||||
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-website", "env": { "GOWORK": "off" } },
|
||||
"dependsOn": ["kjol-website: prebuild"],
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "go",
|
||||
@@ -113,18 +113,18 @@
|
||||
"group": { "kind": "build", "isDefault": true }
|
||||
},
|
||||
{
|
||||
"label": "kjol-web: build (cold)",
|
||||
"label": "kjol-website: build (cold)",
|
||||
"detail": "Cold build, then exit: codegen + Tailwind + wasm + Solid bundle + wasm_exec.js.",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": ["run", "./server", "-build"],
|
||||
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web", "env": { "GOWORK": "off" } },
|
||||
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-website", "env": { "GOWORK": "off" } },
|
||||
"problemMatcher": ["$go"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "kjol: build ./...",
|
||||
"detail": "Build the whole kjol module (does not descend into the nested kjol-web module)",
|
||||
"detail": "Build the whole kjol module (does not descend into the nested kjol-website module)",
|
||||
"type": "shell",
|
||||
"command": "go build ./...",
|
||||
"options": { "cwd": "${workspaceFolder}/go" },
|
||||
@@ -161,11 +161,11 @@
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "kjol-web: test",
|
||||
"label": "kjol-website: test",
|
||||
"detail": "The site's own tests (SSR, the icon registry, the AutoTable export bytes). Its own module, so `kjol: test ./...` does not reach it.",
|
||||
"type": "shell",
|
||||
"command": "go test ./...",
|
||||
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web", "env": { "GOWORK": "off" } },
|
||||
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-website", "env": { "GOWORK": "off" } },
|
||||
"problemMatcher": ["$go"],
|
||||
"group": "test"
|
||||
}
|
||||
|
||||
@@ -59,10 +59,10 @@ plus `HighlightGo` / `HighlightC`. It is a lexer and not a parser on purpose —
|
||||
escaped plain text rather than failing, and an unknown language is not an error. It is outside
|
||||
`webui` because it touches no DOM. **Its output is Tailwind class names**, so any stylesheet that
|
||||
has to render a code block must scan `lexer/**/*.go` — a build that forgets to still compiles and
|
||||
just renders the snippet unstyled (see `cmd/kjol-web/build.Tailwind`). The C tables deliberately
|
||||
just renders the snippet unstyled (see `cmd/kjol-website/build.Tailwind`). The C tables deliberately
|
||||
mirror `c/lexer/lexer_c.c`; add a type to one, add it to the other.
|
||||
|
||||
**`cmd/kjol-web` is the website**: the landing page and documentation for the whole
|
||||
**`cmd/kjol-website` is the website**: the landing page and documentation for the whole
|
||||
codebase, and the runnable example of both web layers. It is its own nested module (so its
|
||||
go-chart / esbuild / goja deps stay out of kjol) and it is ONE server running TWO
|
||||
front-ends — `/wasm/*` is the Go→WebAssembly SPA, `/js/*` is the Solid SPA, and `/` is a
|
||||
@@ -135,7 +135,7 @@ get a white flash until the bundle loads. It is the only hand-written JavaScript
|
||||
AutoTable must vendor them or the bundle fails to evaluate at all.
|
||||
- `icons/` — FontAwesome SVG source kit (the bundler scans usage and generates a per-app
|
||||
registry; the generated file is app-owned, not committed here). kjol ships only the SUBSET its
|
||||
own kit + `kjol-web` reference. An app's own `frontend/icons` is searched FIRST, so an app with
|
||||
own kit + `kjol-website` reference. An app's own `frontend/icons` is searched FIRST, so an app with
|
||||
a fuller kit keeps it — see `jsbundler.iconsDirs`.
|
||||
- `styles/theme.css` — the `@theme` scaffold, the semantic tokens, the `.dark` overrides, and the
|
||||
`:root` fa vars. **It does the `@import "tailwindcss"`**, because the bundler PREPENDS it to the
|
||||
@@ -181,7 +181,7 @@ class out in full.
|
||||
| `appenv` | compile-time environment via build tags (`-tags staging` / `-tags production`); the bundler reads `appenv.Environment` for the JS `__ENV_TYPE__` define |
|
||||
| `httputil.CorsMiddleware(CorsConfig{...})` | allowed domains + bundle-version source injected |
|
||||
| `wasmdevserver` | the app injects `Build` / `Render` / `Document` / `Handle` via `Config` |
|
||||
| `jsbundler` public pages | kjol generates the registry (`public_pages.gen.go`); the APP owns the `publicPage` type it is written against, and the document shell. See `cmd/kjol-web/internal/handlers`. |
|
||||
| `jsbundler` public pages | kjol generates the registry (`public_pages.gen.go`); the APP owns the `publicPage` type it is written against, and the document shell. See `cmd/kjol-website/internal/handlers`. |
|
||||
|
||||
## Stays app-side (never moves into kjol)
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
Stack trace:
|
||||
Frame Function Args
|
||||
0007FFFFBD80 000210060304 (0007FFFFBF88, 0007FFFFCE00, 000000000002, 0007FFFFDC10) msys-2.0.dll+0x20304
|
||||
FFFFFFFFFFFEFEDF 00021006237D (0007FFFFC730, 000000000000, 00000000017C, 000000000000) msys-2.0.dll+0x2237D
|
||||
0007FFFFC490 0002100C1394 (0007FFFFC6F0, 000000000001, 0007FFFFC700, 000000000000) msys-2.0.dll+0x81394
|
||||
000000000006 0002100BCE39 (000210221CCD, 000000000000, 000000000000, 7FF9520C00E8) msys-2.0.dll+0x7CE39
|
||||
0007FFFFC848 0002100BD23A (0007FFFFC858, 000A00000000, 0000000000B5, 0000000000B5) msys-2.0.dll+0x7D23A
|
||||
0007FFFFC848 0002102130B8 (000000000000, 000A00000030, 000000000000, 000000000000) msys-2.0.dll+0x1D30B8
|
||||
0007FFFFC848 00010042A1E5 (0002100A76B3, 7FF900000000, 000210221CCD, 000100000001) grep.exe+0x2A1E5
|
||||
0007FFFFCB80 000100404B7C (00000000000D, 000000000000, 000000000000, 000000000000) grep.exe+0x4B7C
|
||||
0007FFFFCB80 000100429678 (0002100455E0, 000000000000, 000000000148, 000000000000) grep.exe+0x29678
|
||||
0007FFFFCD30 000210047F01 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x7F01
|
||||
000000000000 000210045AC3 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x5AC3
|
||||
0007FFFFFFF0 000210045B74 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x5B74
|
||||
End of stack trace
|
||||
Loaded modules:
|
||||
000100400000 grep.exe
|
||||
7FF952CB0000 ntdll.dll
|
||||
7FF9520C0000 KERNEL32.DLL
|
||||
7FF94FFF0000 KERNELBASE.dll
|
||||
0005603F0000 msys-iconv-2.dll
|
||||
000430B30000 msys-intl-8.dll
|
||||
000210040000 msys-2.0.dll
|
||||
0004C36D0000 msys-pcre-1.dll
|
||||
7FF951F50000 advapi32.dll
|
||||
7FF9525E0000 msvcrt.dll
|
||||
7FF952010000 sechost.dll
|
||||
7FF9503A0000 bcrypt.dll
|
||||
7FF952380000 RPCRT4.dll
|
||||
7FF94F720000 CRYPTBASE.DLL
|
||||
7FF9508C0000 bcryptPrimitives.dll
|
||||
@@ -1,200 +0,0 @@
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: italic;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: italic;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: italic;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: italic;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* hebrew */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: italic;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2');
|
||||
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
|
||||
}
|
||||
/* math */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: italic;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2');
|
||||
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
|
||||
}
|
||||
/* symbols */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: italic;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2');
|
||||
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: italic;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: italic;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: italic;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* hebrew */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2');
|
||||
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
|
||||
}
|
||||
/* math */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2');
|
||||
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
|
||||
}
|
||||
/* symbols */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2');
|
||||
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/opensans/v44/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# kjol-web — the kjol website
|
||||
# kjol-website — the kjol website
|
||||
|
||||
The landing page and documentation for the whole codebase. It is also the thing it
|
||||
documents: every page runs the code it describes, and there is not a screenshot of a
|
||||
@@ -12,7 +12,7 @@ build the site out of both.
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
cd go/cmd/kjol-web
|
||||
cd go/cmd/kjol-website
|
||||
go run ./server -build # cold build, then exit
|
||||
go run ./server # build, then SSR + /rsc + hot reload at http://localhost:8085
|
||||
```
|
||||
@@ -121,7 +121,7 @@ one way.
|
||||
|
||||
## Its own module
|
||||
|
||||
`go.mod` declares module `kjolweb` with `replace kjol => ../..`, so its dependencies —
|
||||
`go.mod` declares module `kjolwebsite` with `replace kjol => ../..`, so its dependencies —
|
||||
go-chart for the server-drawn charts, plus esbuild and goja by way of the bundler — stay
|
||||
out of kjol, whose engine packages are stdlib-only. `go build ./...` at the kjol root
|
||||
does not descend into this nested module; build it from here.
|
||||
@@ -618,7 +618,7 @@ func tablesSection() func() *VNode {
|
||||
}
|
||||
return ui.AutoTablePDFHeader{
|
||||
Title: "Employees",
|
||||
Subtitle: "Exported from the kjol-web components page",
|
||||
Subtitle: "Exported from the kjol-website components page",
|
||||
ShowDate: true,
|
||||
Orientation: orientation,
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package app holds the kjol-web site's Go/WASM pages and components as
|
||||
// Package app holds the kjol-website site's Go/WASM pages and components as
|
||||
// standalone, platform-neutral functions (SSR on the server, hydrate on the
|
||||
// client). UI is built from the kjol webui kit + Tailwind utility classes.
|
||||
//
|
||||
@@ -106,13 +106,13 @@ func Codegen() ([]byte, error) {
|
||||
// names, and nothing else in the tree mentions text-teal-300.
|
||||
func Tailwind() ([]byte, error) {
|
||||
cmd := exec.Command("go", "run", "./cmd/twcss",
|
||||
"-entry", "cmd/kjol-web/css/app.css",
|
||||
"-out", "cmd/kjol-web/wwwroot/app.css",
|
||||
"-entry", "cmd/kjol-website/css/app.css",
|
||||
"-out", "cmd/kjol-website/wwwroot/app.css",
|
||||
"-base", ".",
|
||||
"webui/**/*.go",
|
||||
"lexer/**/*.go",
|
||||
"cmd/kjol-web/app/**/*.go",
|
||||
"cmd/kjol-web/server/**/*.go",
|
||||
"cmd/kjol-website/app/**/*.go",
|
||||
"cmd/kjol-website/server/**/*.go",
|
||||
)
|
||||
cmd.Dir = kjolRoot
|
||||
return cmd.CombinedOutput()
|
||||
@@ -1,5 +1,5 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
kjol-web — brand stylesheet for the Kjol JS Web section (/js/*).
|
||||
kjol-website — brand stylesheet for the Kjol JS Web section (/js/*).
|
||||
---------------------------------------------------------------------------
|
||||
There is deliberately no `@import "tailwindcss"` here. The bundler PREPENDS
|
||||
kjol's shared scaffold (go/jsruntime/styles/theme.css) to this file, and that
|
||||
@@ -84,6 +84,7 @@ import { Accordion, SingleAccordion } from "@ui/Accordion";
|
||||
import { SidebarNav } from "@ui/Sidebar";
|
||||
import { FuzzyMatch } from "@ui/FuzzyMatch";
|
||||
import { Chart, ChartSeries } from "@ui/Chart";
|
||||
import { USHeatmap } from "@ui/USHeatmap";
|
||||
import { ThemeToggle, useTheme } from "@ui/Theme";
|
||||
|
||||
import { Demo } from "../layout/Demo.tsx";
|
||||
@@ -464,7 +465,7 @@ function Forms() {
|
||||
email() && !isEmailValid(email()) ? "That is not an email address." : "";
|
||||
|
||||
<FormEmailInput
|
||||
value={email}
|
||||
value={email()}
|
||||
oninput={(e) => setEmail(e.currentTarget.value)}
|
||||
error={emailError()}
|
||||
showIcon
|
||||
@@ -474,21 +475,21 @@ function Forms() {
|
||||
<Field label="Name">
|
||||
<FormInput
|
||||
placeholder="Ada Lovelace"
|
||||
value={name}
|
||||
value={name()}
|
||||
oninput={(e) => setName(e.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Email">
|
||||
<FormEmailInput
|
||||
placeholder="ada@example.com"
|
||||
value={email}
|
||||
value={email()}
|
||||
oninput={(e) => setEmail(e.currentTarget.value)}
|
||||
error={emailError()}
|
||||
showIcon
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Plan">
|
||||
<FormSelect value={plan} onchange={(e) => setPlan(e.currentTarget.value)}>
|
||||
<FormSelect value={plan()} onchange={(e) => setPlan(e.currentTarget.value)}>
|
||||
<option value="free">Free</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
@@ -498,7 +499,7 @@ function Forms() {
|
||||
<FormTextarea
|
||||
rows={3}
|
||||
placeholder="Anything worth remembering…"
|
||||
value={notes}
|
||||
value={notes()}
|
||||
oninput={(e) => setNotes(e.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
@@ -532,11 +533,11 @@ function Forms() {
|
||||
<FormFieldset legend="Account">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Name">
|
||||
<FormInput value={name} oninput={(e) => setName(e.currentTarget.value)} />
|
||||
<FormInput value={name()} oninput={(e) => setName(e.currentTarget.value)} />
|
||||
</Field>
|
||||
<Field label="Email (validated)">
|
||||
<FormEmailInput
|
||||
value={email}
|
||||
value={email()}
|
||||
oninput={(e) => setEmail(e.currentTarget.value)}
|
||||
error={emailError()}
|
||||
showIcon
|
||||
@@ -590,7 +591,7 @@ function Selects() {
|
||||
<Field label="Language (searchable, one)">
|
||||
<FormCombobox
|
||||
options={LANGUAGES}
|
||||
value={one}
|
||||
value={one()}
|
||||
onchange={setOne}
|
||||
searchable
|
||||
placeholder="Pick one"
|
||||
@@ -607,10 +608,10 @@ function Selects() {
|
||||
/>
|
||||
</Field>
|
||||
<Field label="State">
|
||||
<FormCombobox options={US_STATES} value={state} onchange={setState} searchable />
|
||||
<FormCombobox options={US_STATES} value={state()} onchange={setState} searchable />
|
||||
</Field>
|
||||
<Field label="Timezone">
|
||||
<FormTimezoneSelector value={tz} onchange={(e: any) => setTz(e?.currentTarget?.value ?? e)} />
|
||||
<FormTimezoneSelector value={tz()} onchange={(e: any) => setTz(e?.currentTarget?.value ?? e)} />
|
||||
</Field>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-ink-muted">
|
||||
@@ -722,10 +723,10 @@ function Dates() {
|
||||
<Panel title={"Pickers — picked: " + (date() || "nothing")}>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Date (portaled; flips near the bottom)">
|
||||
<DatePicker value={date} onchange={setDate} clearable placeholder="Pick a date" />
|
||||
<DatePicker value={date()} onchange={setDate} clearable placeholder="Pick a date" />
|
||||
</Field>
|
||||
<Field label="Date of birth (inline, month/year selects)">
|
||||
<DateOfBirthPicker value={dob} onchange={setDob} />
|
||||
<DateOfBirthPicker value={dob()} onchange={setDob} />
|
||||
</Field>
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -1125,7 +1126,7 @@ function Overlays() {
|
||||
return (
|
||||
<Field label="Name (required to continue)">
|
||||
<FormInput
|
||||
value={wizardName}
|
||||
value={wizardName()}
|
||||
placeholder="Ada Lovelace"
|
||||
oninput={(e) => setWizardName(e.currentTarget.value)}
|
||||
/>
|
||||
@@ -1366,8 +1367,29 @@ function Search() {
|
||||
|
||||
const CHART_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
// A made-up per-state metric for the choropleth, and a few cities (lat/lng) to drop on
|
||||
// top of it — Anchorage and Honolulu included, to land on albersUsa's AK/HI insets.
|
||||
const US_SIGNUPS: Record<string, number> = {
|
||||
CA: 4820, TX: 3910, NY: 3120, FL: 2870, IL: 1740, PA: 1610, OH: 1490, GA: 1450,
|
||||
NC: 1360, MI: 1280, WA: 1230, AZ: 1180, MA: 1120, VA: 1090, CO: 980, TN: 940,
|
||||
NJ: 910, OR: 720, MN: 690, WI: 610, MO: 560, MD: 540, IN: 520, NV: 480,
|
||||
UT: 430, AL: 390, SC: 360, KY: 310, LA: 300, OK: 280, CT: 260, IA: 210,
|
||||
KS: 180, AK: 140, HI: 160, ME: 120, MT: 90, WY: 60, ND: 70, SD: 80,
|
||||
};
|
||||
const US_CITIES = [
|
||||
{ label: "Seattle", lat: 47.6062, lng: -122.3321, value: 1230 },
|
||||
{ label: "San Francisco", lat: 37.7749, lng: -122.4194, value: 2110 },
|
||||
{ label: "Denver", lat: 39.7392, lng: -104.9903, value: 980 },
|
||||
{ label: "Chicago", lat: 41.8781, lng: -87.6298, value: 1740 },
|
||||
{ label: "New York", lat: 40.7128, lng: -74.006, value: 3120 },
|
||||
{ label: "Miami", lat: 25.7617, lng: -80.1918, value: 1460 },
|
||||
{ label: "Anchorage", lat: 61.2181, lng: -149.9003, value: 140 },
|
||||
{ label: "Honolulu", lat: 21.3069, lng: -157.8583, value: 160 },
|
||||
];
|
||||
|
||||
function Charts() {
|
||||
const [seed, setSeed] = createSignal(0);
|
||||
const [threeD, setThreeD] = createSignal(false);
|
||||
|
||||
// Two series over the same week. seed() reshuffles them so you can watch the SVG
|
||||
// move — no teardown, no new instance, just the marks that changed.
|
||||
@@ -1377,6 +1399,7 @@ function Charts() {
|
||||
const errors = (): ChartSeries => ({ name: "Errors", data: shuffle([8, 3, 12, 6, 9, 2, 14]) });
|
||||
const pieData = (): ChartSeries => ({ name: "Traffic", data: shuffle([40, 25, 20, 15, 8]) });
|
||||
const pieLabels = ["Direct", "Search", "Social", "Email", "Referral"];
|
||||
const regions = ["East", "Central", "Mountain", "Pacific"];
|
||||
|
||||
return (
|
||||
<Section id="charts" title="Charts">
|
||||
@@ -1396,10 +1419,10 @@ function Charts() {
|
||||
<Panel title="Grouped bars, a smooth area, a two-line series, and a donut — from one data set">
|
||||
<div class="grid gap-6 lg:grid-cols-12">
|
||||
<div class="lg:col-span-7">
|
||||
<Chart kind="bar" labels={CHART_DAYS} series={[requests(), errors()]} height={260} />
|
||||
<Chart kind="bar" labels={CHART_DAYS} series={[requests(), errors()]} height={260} threeD={threeD()} />
|
||||
</div>
|
||||
<div class="lg:col-span-5">
|
||||
<Chart kind="donut" labels={pieLabels} series={[pieData()]} height={260} />
|
||||
<Chart kind="donut" labels={pieLabels} series={[pieData()]} height={260} threeD={threeD()} />
|
||||
</div>
|
||||
<div class="lg:col-span-7">
|
||||
<Chart kind="area" curve="smooth" labels={CHART_DAYS} series={[requests()]} height={220} />
|
||||
@@ -1412,8 +1435,12 @@ function Charts() {
|
||||
<ButtonUI color={BUTTON_COLOR_PRIMARY} small onclick={() => setSeed(seed() + 1)}>
|
||||
New data
|
||||
</ButtonUI>
|
||||
<ButtonUI color={BUTTON_COLOR_NEUTRAL} small onclick={() => setThreeD(!threeD())}>
|
||||
{threeD() ? "Flat" : "3D"}
|
||||
</ButtonUI>
|
||||
<span class="text-xs text-ink-muted">
|
||||
Every chart updates in place — hover any of them for the tooltip.
|
||||
The one <code class="font-mono">threeD</code> prop extrudes the bars and tilts the donut
|
||||
(line and area stay flat — depth just reads as noise on a curve).
|
||||
</span>
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -1421,10 +1448,38 @@ function Charts() {
|
||||
<Prose>
|
||||
One <code class="font-mono">kind</code> prop picks the form —{" "}
|
||||
<code class="font-mono">"line" | "area" | "bar" | "pie" | "donut"</code> — and{" "}
|
||||
<code class="font-mono">stacked</code>, <code class="font-mono">curve</code>,{" "}
|
||||
<code class="font-mono">stacked</code>, <code class="font-mono">horizontal</code>,{" "}
|
||||
<code class="font-mono">curve</code>, <code class="font-mono">threeD</code>,{" "}
|
||||
<code class="font-mono">palette</code> and <code class="font-mono">valueFormat</code> refine it.
|
||||
The width is measured from the container, so a chart fills whatever column you give it.
|
||||
</Prose>
|
||||
|
||||
<Panel title="Horizontal bars, and stacked — the same kind, transposed and layered">
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<Chart kind="bar" horizontal labels={CHART_DAYS} series={[requests(), errors()]} height={260} />
|
||||
<Chart kind="bar" stacked labels={regions}
|
||||
series={[
|
||||
{ name: "Requests", data: shuffle([42, 55, 28, 63]) },
|
||||
{ name: "Errors", data: shuffle([8, 9, 6, 12]) },
|
||||
{ name: "Retries", data: shuffle([5, 7, 3, 9]) },
|
||||
]} height={260} />
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-ink-muted">
|
||||
<code class="font-mono">horizontal</code> runs the categories down the y-axis;{" "}
|
||||
<code class="font-mono">stacked</code> layers the series with a 2px surface gap between segments.
|
||||
</p>
|
||||
</Panel>
|
||||
|
||||
<Panel title="US heatmap — a value per state, with proportional lat/lng points on top">
|
||||
<USHeatmap data={US_SIGNUPS} points={US_CITIES} proportional valueFormat={(v) => v.toLocaleString("en-US")} />
|
||||
<p class="mt-3 text-xs text-ink-muted">
|
||||
<code class="font-mono">@ui/USHeatmap</code> shades each state on a themed sequential ramp and
|
||||
projects <code class="font-mono">points</code> (latitude/longitude) with a dependency-free
|
||||
albersUsa port — so Anchorage and Honolulu land on the Alaska and Hawaii insets. With{" "}
|
||||
<code class="font-mono">proportional</code>, each dot's area scales with its value. Hover a
|
||||
state or a point.
|
||||
</p>
|
||||
</Panel>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
8
go/cmd/kjol-website/frontend/src/vendor.d.ts
vendored
Normal file
8
go/cmd/kjol-website/frontend/src/vendor.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
// Ambient shims for tsserver only. The vendored pdf-lib / pdfjs-dist here are trimmed to
|
||||
// the runtime files the bundler pins (frontend/vendor/vendor.json), so their `.d.ts` type
|
||||
// trees are absent and each package.json `types` field points at a file that was not
|
||||
// vendored. @ui/AutoTable imports both; the bundler resolves them at build time, but the
|
||||
// editor needs a declaration or it reports "cannot find module". These make them `any`,
|
||||
// which is all this example needs — it does not exercise the PDF export path itself.
|
||||
declare module "pdf-lib";
|
||||
declare module "pdfjs-dist";
|
||||
@@ -1,7 +1,7 @@
|
||||
// The example is its own module so its go-chart dependency (and freetype /
|
||||
// x/image) stays out of the kjol module — kjol's engine packages are
|
||||
// stdlib-only. kjol is resolved locally via the replace below (no publish step).
|
||||
module kjolweb
|
||||
module kjolwebsite
|
||||
|
||||
go 1.26.3
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Command server runs the kjol-web site on kjol's reusable wasmdevserver:
|
||||
// Command server runs the kjol-website site on kjol's reusable wasmdevserver:
|
||||
// it SSRs the app's static routes, hosts the /rsc server-component endpoint, and
|
||||
// hot-swaps the wasm into the browser on change. It shows the coupling
|
||||
// inversion — the framework (wasmdevserver) imports no app code; the app injects
|
||||
@@ -6,7 +6,7 @@
|
||||
//
|
||||
// Run it from THIS directory (the relative paths below are resolved against it):
|
||||
//
|
||||
// go run ./server # from go/cmd/kjol-web
|
||||
// go run ./server # from go/cmd/kjol-website
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -20,9 +20,9 @@ import (
|
||||
"kjol/wasmdevserver"
|
||||
"kjol/webui"
|
||||
|
||||
"kjolweb/app"
|
||||
"kjolweb/build"
|
||||
"kjolweb/internal/handlers"
|
||||
"kjolwebsite/app"
|
||||
"kjolwebsite/build"
|
||||
"kjolwebsite/internal/handlers"
|
||||
)
|
||||
|
||||
func main() {
|
||||
32
go/cmd/kjol-website/tsconfig.json
Normal file
32
go/cmd/kjol-website/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
// TS config for the kjol-website front-end. The bundler resolves the @ui / @kjol / @appgen
|
||||
// aliases at build time; the editor's TypeScript server needs them declared here or it
|
||||
// reports every `import … from "@ui/…"` as an unresolved module. Mirrors the shape of a
|
||||
// consuming app's root tsconfig, with the paths made relative to this nested example
|
||||
// (the shared JS tree lives two levels up at go/jsruntime).
|
||||
"compilerOptions": {
|
||||
"target": "ES2025",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"noEmit": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": false,
|
||||
"lib": ["ES2025", "DOM", "DOM.Iterable"],
|
||||
"paths": {
|
||||
"@ui/*": ["../../jsruntime/uikit/*"],
|
||||
"@kjol/*": ["../../jsruntime/*"],
|
||||
"@appgen/*": ["./frontend/src/ui/generated/*"],
|
||||
"*": ["../../jsruntime/runtime/*", "./frontend/vendor/*"]
|
||||
}
|
||||
},
|
||||
"include": ["frontend/**/*", "../../jsruntime/**/*"],
|
||||
"exclude": ["frontend/css/**/*", "frontend/vendor/**/*", "../../jsruntime/runtime/**/*", "../../jsruntime/types.d.ts"]
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"kjolweb/app"
|
||||
"kjolwebsite/app"
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime"
|
||||
)
|
||||
@@ -2,7 +2,7 @@
|
||||
// scanning explicit content globs for utility candidates. Unlike the app bundler
|
||||
// (which is wired to the frontend tree) it takes the entry, output, and content
|
||||
// globs as flags/args, so it works for markup authored in any language — used by
|
||||
// the kjol-web site, whose Go/WASM half writes its UI in Go.
|
||||
// the kjol-website site, whose Go/WASM half writes its UI in Go.
|
||||
//
|
||||
// Usage (globs are relative to -base; pass "**" for a recursive walk):
|
||||
//
|
||||
|
||||
@@ -17,7 +17,7 @@ Only the styles in `jsbundler`'s `faStyleDirs` are read — currently `regular/`
|
||||
<https://fontawesome.com/license>
|
||||
|
||||
This is a **subset**, not the full kit: the ~43 icons the `uikit/` components and
|
||||
the `kjol-web` example actually reference, in `regular` and `solid`. The full kit
|
||||
the `kjol-website` example actually reference, in `regular` and `solid`. The full kit
|
||||
is ~81,000 files across 17 style directories and has no business in a shared
|
||||
submodule.
|
||||
|
||||
|
||||
@@ -164,6 +164,18 @@
|
||||
--color-chart-6: #e34948; /* red */
|
||||
--color-chart-7: #e87ba4; /* magenta */
|
||||
--color-chart-8: #eb6834; /* orange */
|
||||
|
||||
/* Sequential ramp for the choropleth (uikit/USHeatmap): one hue, light→dark, six
|
||||
steps low→high. Unlike the categorical slots it means MAGNITUDE, so it is a single
|
||||
blue stepped by lightness. Dark mode re-points it below: on a near-black surface a
|
||||
high value must read as BRIGHTER, not darker, so the ramp inverts its lightness
|
||||
direction while keeping the same hue. */
|
||||
--color-choropleth-1: #dbe9fb;
|
||||
--color-choropleth-2: #b3d0f6;
|
||||
--color-choropleth-3: #85b3ee;
|
||||
--color-choropleth-4: #5591e4;
|
||||
--color-choropleth-5: #2f6fca;
|
||||
--color-choropleth-6: #124f8f;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
@@ -218,6 +230,15 @@
|
||||
--color-chart-6: #e66767;
|
||||
--color-chart-7: #d55181;
|
||||
--color-chart-8: #d95926;
|
||||
|
||||
/* Same blue hue, stepped for the dark surface and inverted in direction: step 1 (low)
|
||||
is the dimmest, step 6 (high) the brightest, so "more" reads as "brighter". */
|
||||
--color-choropleth-1: #1b2a44;
|
||||
--color-choropleth-2: #21406c;
|
||||
--color-choropleth-3: #2c5f97;
|
||||
--color-choropleth-4: #3f80c8;
|
||||
--color-choropleth-5: #649de8;
|
||||
--color-choropleth-6: #93c2f7;
|
||||
}
|
||||
|
||||
/* The page's own background — painted before anything mounts, and behind it
|
||||
|
||||
@@ -41,25 +41,32 @@ export interface ChartProps {
|
||||
|
||||
// bar / area: stack the series instead of grouping them side by side.
|
||||
stacked?: boolean;
|
||||
// bar only: lay the bars horizontally — categories run down the y-axis, values along x.
|
||||
horizontal?: boolean;
|
||||
// line / area: "smooth" draws a Catmull-Rom spline through the points.
|
||||
curve?: "linear" | "smooth";
|
||||
// donut only: inner-radius fraction of the outer radius (0.6 by default).
|
||||
donutRatio?: number;
|
||||
|
||||
// Give the chart depth: bars extrude, pie/donut tilt and gain a rim (line/area ignore
|
||||
// it). An embellishment — flat reads more precisely — but sometimes wanted.
|
||||
threeD?: boolean;
|
||||
depth?: number; // 3D extrusion depth in px (default 16).
|
||||
|
||||
height?: number; // px of the plot area (default 300). The legend adds its own height.
|
||||
width?: number; // fix the width instead of measuring the container.
|
||||
class?: string;
|
||||
|
||||
// Override the whole categorical palette (else the --color-chart-1..8 tokens).
|
||||
palette?: string[];
|
||||
// Format a value for the y-axis ticks and the tooltip. Defaults to en-US grouping.
|
||||
// Format a value for the value-axis ticks and the tooltip. Defaults to en-US grouping.
|
||||
valueFormat?: (v: number) => string;
|
||||
|
||||
legend?: boolean; // default: true when there is more than one series (or a pie).
|
||||
grid?: boolean; // cartesian only; default true.
|
||||
axes?: boolean; // cartesian only; default true.
|
||||
tooltip?: boolean; // default true.
|
||||
yMin?: number; // pin the y domain instead of deriving it from the data.
|
||||
yMin?: number; // pin the value domain instead of deriving it from the data.
|
||||
yMax?: number;
|
||||
}
|
||||
|
||||
@@ -77,6 +84,7 @@ const BAR_MAX_W = 24; // cap a bar's thickness; the band's leftover is de
|
||||
const BAR_RADIUS = 4; // rounded data-end
|
||||
const SEG_GAP = 2; // the surface gap between touching marks (stacked segments)
|
||||
const MARK_R = 4; // hover marker radius (8px mark)
|
||||
const DEFAULT_DEPTH = 16; // 3D extrusion depth
|
||||
|
||||
// ── number + geometry helpers ───────────────────────────────────────────────────
|
||||
|
||||
@@ -122,17 +130,29 @@ function niceScale(min: number, max: number, maxTicks = 5): { min: number; max:
|
||||
return { min: niceMin, max: niceMax, ticks };
|
||||
}
|
||||
|
||||
// A column with the two corners at its VALUE end rounded and the baseline end square —
|
||||
// the mark spec. Handles growing up or down from the baseline.
|
||||
function columnPath(x: number, w: number, yBase: number, yVal: number, r: number): string {
|
||||
const h = Math.abs(yBase - yVal);
|
||||
const rr = Math.max(0, Math.min(r, w / 2, h));
|
||||
if (yVal <= yBase) {
|
||||
const t = yVal;
|
||||
return `M${x},${yBase} L${x},${t + rr} Q${x},${t} ${x + rr},${t} L${x + w - rr},${t} Q${x + w},${t} ${x + w},${t + rr} L${x + w},${yBase} Z`;
|
||||
// A rectangle with a chosen subset of corners rounded — the data-end of a bar rounds,
|
||||
// the baseline end stays square, and which end that is depends on orientation and sign.
|
||||
function roundRectPath(x: number, y: number, w: number, h: number, r: number, side: BarSide): string {
|
||||
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
|
||||
const tl = side === "top" || side === "left" ? rr : 0;
|
||||
const tr = side === "top" || side === "right" ? rr : 0;
|
||||
const br = side === "bottom" || side === "right" ? rr : 0;
|
||||
const bl = side === "bottom" || side === "left" ? rr : 0;
|
||||
return `M${x + tl},${y} L${x + w - tr},${y} Q${x + w},${y} ${x + w},${y + tr}` +
|
||||
` L${x + w},${y + h - br} Q${x + w},${y + h} ${x + w - br},${y + h}` +
|
||||
` L${x + bl},${y + h} Q${x},${y + h} ${x},${y + h - bl}` +
|
||||
` L${x},${y + tl} Q${x},${y} ${x + tl},${y} Z`;
|
||||
}
|
||||
const b = yVal;
|
||||
return `M${x},${yBase} L${x},${b - rr} Q${x},${b} ${x + rr},${b} L${x + w - rr},${b} Q${x + w},${b} ${x + w},${b - rr} L${x + w},${yBase} Z`;
|
||||
|
||||
// A bar extruded up-and-right by (dx, dy): a right side face (darkened), a top face
|
||||
// (lightened) and the front face. The overlays are flat black/white washes so the shading
|
||||
// needs no colour maths on a CSS variable it cannot read at build time.
|
||||
function bar3D(x: number, y: number, w: number, h: number, color: string, op: number, dx: number, dy: number): string {
|
||||
const top = `M${x},${y} L${x + dx},${y - dy} L${x + w + dx},${y - dy} L${x + w},${y} Z`;
|
||||
const right = `M${x + w},${y} L${x + w + dx},${y - dy} L${x + w + dx},${y + h - dy} L${x + w},${y + h} Z`;
|
||||
return `<path d="${right}" fill="${color}" fill-opacity="${op}"/><path d="${right}" fill="#000" fill-opacity="${0.24 * op}"/>` +
|
||||
`<path d="${top}" fill="${color}" fill-opacity="${op}"/><path d="${top}" fill="#fff" fill-opacity="${0.2 * op}"/>` +
|
||||
`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${color}" fill-opacity="${op}"/>`;
|
||||
}
|
||||
|
||||
function linePathD(pts: [number, number][]): string {
|
||||
@@ -157,22 +177,25 @@ function smoothPathD(pts: [number, number][]): string {
|
||||
return d;
|
||||
}
|
||||
|
||||
function pointOnCircle(cx: number, cy: number, r: number, deg: number): [number, number] {
|
||||
// A point on a circle tilted about its horizontal axis by factor k (k=1 is upright): the
|
||||
// vertical radius shrinks to k·r, so the circle reads as an ellipse seen at an angle.
|
||||
function tiltPoint(cx: number, cy: number, r: number, deg: number, k: number): [number, number] {
|
||||
const a = (deg - 90) * Math.PI / 180; // 0° at 12 o'clock, clockwise
|
||||
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
|
||||
return [cx + r * Math.cos(a), cy + k * r * Math.sin(a)];
|
||||
}
|
||||
|
||||
// One pie/donut slice from a0 to a1 degrees. rIn === 0 gives a pie wedge.
|
||||
function slicePathD(cx: number, cy: number, rOut: number, rIn: number, a0: number, a1: number): string {
|
||||
// One pie/donut slice from a0 to a1 degrees, tilted by k (k=1 upright). rIn === 0 gives a
|
||||
// pie wedge. Uses elliptical arcs so the tilt is exact, not a polygon approximation.
|
||||
function slicePathD(cx: number, cy: number, rOut: number, rIn: number, a0: number, a1: number, k = 1): string {
|
||||
const large = a1 - a0 > 180 ? 1 : 0;
|
||||
const [ox0, oy0] = pointOnCircle(cx, cy, rOut, a0);
|
||||
const [ox1, oy1] = pointOnCircle(cx, cy, rOut, a1);
|
||||
const [ox0, oy0] = tiltPoint(cx, cy, rOut, a0, k);
|
||||
const [ox1, oy1] = tiltPoint(cx, cy, rOut, a1, k);
|
||||
if (rIn <= 0) {
|
||||
return `M${cx},${cy} L${ox0},${oy0} A${rOut},${rOut} 0 ${large} 1 ${ox1},${oy1} Z`;
|
||||
return `M${cx},${cy} L${ox0},${oy0} A${rOut},${k * rOut} 0 ${large} 1 ${ox1},${oy1} Z`;
|
||||
}
|
||||
const [ix1, iy1] = pointOnCircle(cx, cy, rIn, a1);
|
||||
const [ix0, iy0] = pointOnCircle(cx, cy, rIn, a0);
|
||||
return `M${ox0},${oy0} A${rOut},${rOut} 0 ${large} 1 ${ox1},${oy1} L${ix1},${iy1} A${rIn},${rIn} 0 ${large} 0 ${ix0},${iy0} Z`;
|
||||
const [ix1, iy1] = tiltPoint(cx, cy, rIn, a1, k);
|
||||
const [ix0, iy0] = tiltPoint(cx, cy, rIn, a0, k);
|
||||
return `M${ox0},${oy0} A${rOut},${k * rOut} 0 ${large} 1 ${ox1},${oy1} L${ix1},${iy1} A${rIn},${k * rIn} 0 ${large} 0 ${ix0},${iy0} Z`;
|
||||
}
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
||||
@@ -223,16 +246,27 @@ interface SubProps {
|
||||
fmt: (v: number) => string;
|
||||
}
|
||||
|
||||
type BarSide = "top" | "bottom" | "left" | "right";
|
||||
interface BarMark { x: number; y: number; w: number; h: number; side: BarSide; seriesIdx: number; catIdx: number; value: number; round: boolean; }
|
||||
interface LineMark { seriesIdx: number; line: string; area: string; pts: [number, number][]; }
|
||||
|
||||
// ── cartesian (line / area / bar) ─────────────────────────────────────────────────
|
||||
|
||||
function CartesianChart(p: SubProps): JSXElement {
|
||||
const [hover, setHover] = createSignal<number | null>(null);
|
||||
const [pointerY, setPointerY] = createSignal(0);
|
||||
const [pointer, setPointer] = createSignal<[number, number]>([0, 0]);
|
||||
|
||||
const horiz = () => p.props.kind === "bar" && !!p.props.horizontal;
|
||||
// 3D extrudes bars only; on a line/area it reads as noise, so it is a no-op there.
|
||||
const threeD = () => !!p.props.threeD && p.props.kind === "bar";
|
||||
const depth = () => p.props.depth ?? DEFAULT_DEPTH;
|
||||
const dx = () => (threeD() ? depth() * 0.7 : 0);
|
||||
const dy = () => (threeD() ? depth() * 0.55 : 0);
|
||||
|
||||
const labels = () => p.props.labels ?? p.props.series[0]?.data.map((_, i) => String(i + 1)) ?? [];
|
||||
const n = () => Math.max(labels().length, ...p.props.series.map((s) => s.data.length), 0);
|
||||
|
||||
// The y domain. Stacked bars/areas reach the tallest STACK, not the tallest single
|
||||
// The value domain. Stacked bars/areas reach the tallest STACK, not the tallest single
|
||||
// value; bars and areas always include zero so the baseline is honest.
|
||||
const domain = createMemo(() => {
|
||||
const series = p.props.series;
|
||||
@@ -258,15 +292,24 @@ function CartesianChart(p: SubProps): JSXElement {
|
||||
return scale;
|
||||
});
|
||||
|
||||
// Left margin follows the widest y tick, so labels never clip and never float.
|
||||
// Margins: the value axis wants room for its ticks, the category axis for its labels —
|
||||
// which sides those are on flips with orientation. 3D adds depth to the top and right,
|
||||
// where bars extrude, so nothing clips.
|
||||
const layout = createMemo(() => {
|
||||
const d = domain();
|
||||
const showAxes = p.props.axes ?? true;
|
||||
const tickW = showAxes ? Math.max(...d.ticks.map((t) => p.fmt(t).length)) * 7 + 12 : 8;
|
||||
const left = Math.max(28, tickW);
|
||||
const top = 12;
|
||||
const bottom = showAxes ? 28 : 8;
|
||||
const right = 12;
|
||||
const valTickW = Math.max(...d.ticks.map((t) => p.fmt(t).length), 1) * 7 + 12;
|
||||
const catLabelW = Math.max(...labels().map((s) => s.length), 1) * 7 + 12;
|
||||
let left: number, bottom: number;
|
||||
if (horiz()) {
|
||||
left = showAxes ? Math.max(28, catLabelW) : 8; // category labels on the left
|
||||
bottom = showAxes ? 28 : 8; // value ticks on the bottom
|
||||
} else {
|
||||
left = showAxes ? Math.max(28, valTickW) : 8; // value ticks on the left
|
||||
bottom = showAxes ? 28 : 8; // category labels on the bottom
|
||||
}
|
||||
const top = 12 + dy();
|
||||
const right = 12 + dx();
|
||||
return {
|
||||
left, top, right, bottom,
|
||||
plotW: Math.max(0, p.width - left - right),
|
||||
@@ -274,28 +317,33 @@ function CartesianChart(p: SubProps): JSXElement {
|
||||
};
|
||||
});
|
||||
|
||||
const yToPx = (v: number) => {
|
||||
// valuePos: pixel along the VALUE axis (y for vertical, x for horizontal).
|
||||
// catCenter: pixel of category i along the CATEGORY axis (x for vertical, y for horizontal).
|
||||
const valuePos = (v: number) => {
|
||||
const d = domain(), l = layout();
|
||||
const t = (v - d.min) / (d.max - d.min || 1);
|
||||
return l.top + l.plotH * (1 - t);
|
||||
return horiz() ? l.left + l.plotW * t : l.top + l.plotH * (1 - t);
|
||||
};
|
||||
const bandW = () => layout().plotW / Math.max(1, n());
|
||||
const bandCenter = (i: number) => layout().left + bandW() * (i + 0.5);
|
||||
const baselineY = () => yToPx(clamp(0, domain().min, domain().max));
|
||||
const bandFull = () => (horiz() ? layout().plotH : layout().plotW) / Math.max(1, n());
|
||||
const catStart = () => (horiz() ? layout().top : layout().left);
|
||||
const catCenter = (i: number) => catStart() + bandFull() * (i + 0.5);
|
||||
const baseValue = () => valuePos(clamp(0, domain().min, domain().max));
|
||||
|
||||
// Grouped bar geometry: the series share a centred group that occupies ~72% of the
|
||||
// band; each bar is capped at BAR_MAX_W with a SEG_GAP of air between neighbours.
|
||||
// Bars resolved to plain rectangles + which side is the (rounded) data-end, so the
|
||||
// renderer draws vertical and horizontal bars the same way.
|
||||
const bars = createMemo(() => {
|
||||
if (p.props.kind !== "bar") return [] as BarMark[];
|
||||
const out: BarMark[] = [];
|
||||
const count = n(), bw = bandW(), base = baselineY();
|
||||
const count = n(), bf = bandFull(), base = baseValue(), h = horiz();
|
||||
const series = p.props.series;
|
||||
// rect(bandOffset, thickness, valueA, valueB) → a rectangle in the right orientation.
|
||||
const rect = (off: number, thick: number, va: number, vb: number): { x: number; y: number; w: number; h: number } =>
|
||||
h ? { x: Math.min(va, vb), y: off, w: Math.abs(vb - va), h: thick }
|
||||
: { x: off, y: Math.min(va, vb), w: thick, h: Math.abs(vb - va) };
|
||||
if (p.props.stacked) {
|
||||
const colW = Math.min(BAR_MAX_W, bw * 0.72);
|
||||
const thick = Math.min(BAR_MAX_W, bf * 0.72);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x = layout().left + bw * i + (bw - colW) / 2;
|
||||
// The rounded data-end belongs to the OUTERMOST segment of each arm; the
|
||||
// interior boundaries are separated by the surface gap, not by rounding.
|
||||
const off = catStart() + bf * i + (bf - thick) / 2;
|
||||
let lastPos = -1, lastNeg = -1;
|
||||
for (let s = 0; s < series.length; s++) {
|
||||
const v = series[s].data[i] ?? 0;
|
||||
@@ -309,22 +357,25 @@ function CartesianChart(p: SubProps): JSXElement {
|
||||
const to = from + v;
|
||||
if (v >= 0) accPos = to; else accNeg = to;
|
||||
const isEnd = (v > 0 && s === lastPos) || (v < 0 && s === lastNeg);
|
||||
const inset = isEnd ? 0 : SEG_GAP; // shrink toward the baseline for the 2px gap
|
||||
const yFrom = yToPx(from); // baseline-side edge
|
||||
const yVal = v >= 0 ? yToPx(to) + inset : yToPx(to) - inset;
|
||||
out.push({ x, w: colW, yBase: yFrom, yVal, seriesIdx: s, catIdx: i, value: v, round: isEnd });
|
||||
const inset = isEnd ? 0 : SEG_GAP; // 2px surface gap between segments
|
||||
const vFrom = valuePos(from);
|
||||
// pull the value end toward the baseline by the gap (except the outer end)
|
||||
const vTo = valuePos(to) + (h ? (v >= 0 ? -inset : inset) : (v >= 0 ? inset : -inset));
|
||||
const r = rect(off, thick, vFrom, vTo);
|
||||
out.push({ ...r, side: barSide(h, v), seriesIdx: s, catIdx: i, value: v, round: isEnd });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const nS = Math.max(1, series.length);
|
||||
const groupW = Math.min(bw * 0.72, (BAR_MAX_W + SEG_GAP) * nS);
|
||||
const each = Math.max(1, Math.min(BAR_MAX_W, groupW / nS - SEG_GAP));
|
||||
const groupSize = Math.min(bf * 0.72, (BAR_MAX_W + SEG_GAP) * nS);
|
||||
const each = Math.max(1, Math.min(BAR_MAX_W, groupSize / nS - SEG_GAP));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const gx = layout().left + bw * i + (bw - groupW) / 2;
|
||||
const g = catStart() + bf * i + (bf - groupSize) / 2;
|
||||
for (let s = 0; s < nS; s++) {
|
||||
const v = series[s].data[i] ?? 0;
|
||||
const x = gx + s * (groupW / nS) + (groupW / nS - each) / 2;
|
||||
out.push({ x, w: each, yBase: base, yVal: yToPx(v), seriesIdx: s, catIdx: i, value: v, round: true });
|
||||
const off = g + s * (groupSize / nS) + (groupSize / nS - each) / 2;
|
||||
const r = rect(off, each, base, valuePos(v));
|
||||
out.push({ ...r, side: barSide(h, v), seriesIdx: s, catIdx: i, value: v, round: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,7 +385,7 @@ function CartesianChart(p: SubProps): JSXElement {
|
||||
// Line/area paths, one per series. Stacked areas ride on the running total below.
|
||||
const paths = createMemo(() => {
|
||||
if (p.props.kind !== "line" && p.props.kind !== "area") return [] as LineMark[];
|
||||
const count = n(), base = baselineY();
|
||||
const count = n(), base = baseValue();
|
||||
const smooth = p.props.curve === "smooth";
|
||||
const stackAcc = new Array(count).fill(0);
|
||||
return p.props.series.map((s, si) => {
|
||||
@@ -344,8 +395,8 @@ function CartesianChart(p: SubProps): JSXElement {
|
||||
const v = s.data[i] ?? 0;
|
||||
const yTop = p.props.stacked ? stackAcc[i] + v : v;
|
||||
const yBot = p.props.stacked ? stackAcc[i] : 0;
|
||||
pts.push([bandCenter(i), yToPx(yTop)]);
|
||||
lowerPts.push([bandCenter(i), p.props.stacked ? yToPx(yBot) : base]);
|
||||
pts.push([catCenter(i), valuePos(yTop)]);
|
||||
lowerPts.push([catCenter(i), p.props.stacked ? valuePos(yBot) : base]);
|
||||
if (p.props.stacked) stackAcc[i] = yTop;
|
||||
}
|
||||
const line = smooth ? smoothPathD(pts) : linePathD(pts);
|
||||
@@ -362,34 +413,59 @@ function CartesianChart(p: SubProps): JSXElement {
|
||||
// The whole SVG interior, as a string (see the file header for why innerHTML and not
|
||||
// JSX marks). Recomputed when the data, the size, or the hovered index changes.
|
||||
const body = createMemo(() => {
|
||||
const l = layout(), d = domain(), hv = hover();
|
||||
const l = layout(), d = domain(), hv = hover(), h = horiz();
|
||||
const out: string[] = [];
|
||||
|
||||
// gridlines + value ticks (perpendicular to the value axis)
|
||||
for (const t of d.ticks) {
|
||||
const y = yToPx(t);
|
||||
if (showGrid()) out.push(`<line x1="${l.left}" x2="${l.left + l.plotW}" y1="${y}" y2="${y}" stroke="var(--color-line)" stroke-width="1"/>`);
|
||||
if (showAxes()) out.push(`<text x="${l.left - 8}" y="${y}" text-anchor="end" dominant-baseline="middle" fill="var(--color-ink-faint)" style="font-size:11px;font-variant-numeric:tabular-nums">${esc(p.fmt(t))}</text>`);
|
||||
const vp = valuePos(t);
|
||||
if (showGrid()) {
|
||||
out.push(h
|
||||
? `<line x1="${vp}" x2="${vp}" y1="${l.top}" y2="${l.top + l.plotH}" stroke="var(--color-line)" stroke-width="1"/>`
|
||||
: `<line x1="${l.left}" x2="${l.left + l.plotW}" y1="${vp}" y2="${vp}" stroke="var(--color-line)" stroke-width="1"/>`);
|
||||
}
|
||||
if (showAxes()) {
|
||||
out.push(h
|
||||
? `<text x="${vp}" y="${p.height - 8}" text-anchor="middle" fill="var(--color-ink-faint)" style="font-size:11px;font-variant-numeric:tabular-nums">${esc(p.fmt(t))}</text>`
|
||||
: `<text x="${l.left - 8}" y="${vp}" text-anchor="end" dominant-baseline="middle" fill="var(--color-ink-faint)" style="font-size:11px;font-variant-numeric:tabular-nums">${esc(p.fmt(t))}</text>`);
|
||||
}
|
||||
}
|
||||
|
||||
const by = baselineY();
|
||||
out.push(`<line x1="${l.left}" x2="${l.left + l.plotW}" y1="${by}" y2="${by}" stroke="var(--color-line-strong)" stroke-width="1"/>`);
|
||||
// baseline (the value-0 line), a touch stronger than the grid
|
||||
const bv = baseValue();
|
||||
out.push(h
|
||||
? `<line x1="${bv}" x2="${bv}" y1="${l.top}" y2="${l.top + l.plotH}" stroke="var(--color-line-strong)" stroke-width="1"/>`
|
||||
: `<line x1="${l.left}" x2="${l.left + l.plotW}" y1="${bv}" y2="${bv}" stroke="var(--color-line-strong)" stroke-width="1"/>`);
|
||||
|
||||
// category labels (along the category axis)
|
||||
if (showAxes()) {
|
||||
labels().forEach((lab, i) =>
|
||||
out.push(`<text x="${bandCenter(i)}" y="${p.height - 8}" text-anchor="middle" fill="var(--color-ink-faint)" style="font-size:11px">${esc(lab)}</text>`));
|
||||
out.push(h
|
||||
? `<text x="${l.left - 8}" y="${catCenter(i)}" text-anchor="end" dominant-baseline="middle" fill="var(--color-ink-faint)" style="font-size:11px">${esc(lab)}</text>`
|
||||
: `<text x="${catCenter(i)}" y="${p.height - 8}" text-anchor="middle" fill="var(--color-ink-faint)" style="font-size:11px">${esc(lab)}</text>`));
|
||||
}
|
||||
|
||||
// crosshair (line/area only — a bar reader aims at a bar, not a hairline)
|
||||
if (p.props.tooltip !== false && hv !== null && !isBar()) {
|
||||
const x = bandCenter(hv);
|
||||
out.push(`<line x1="${x}" x2="${x}" y1="${l.top}" y2="${l.top + l.plotH}" stroke="var(--color-line-strong)" stroke-width="1"/>`);
|
||||
const c = catCenter(hv);
|
||||
out.push(`<line x1="${c}" x2="${c}" y1="${l.top}" y2="${l.top + l.plotH}" stroke="var(--color-line-strong)" stroke-width="1"/>`);
|
||||
}
|
||||
|
||||
for (const b of bars()) {
|
||||
// bars — flat or extruded. 3D draws back-to-front so nearer bars overlap farther ones.
|
||||
const bs = bars();
|
||||
if (threeD()) {
|
||||
for (const b of bs) {
|
||||
const op = hv === null || hv === b.catIdx ? 1 : 0.5;
|
||||
out.push(`<path d="${columnPath(b.x, b.w, b.yBase, b.yVal, b.round ? BAR_RADIUS : 0)}" fill="${esc(p.colorOf(b.seriesIdx))}" fill-opacity="${op}"/>`);
|
||||
out.push(bar3D(b.x, b.y, b.w, b.h, esc(p.colorOf(b.seriesIdx)), op, dx(), dy()));
|
||||
}
|
||||
} else {
|
||||
for (const b of bs) {
|
||||
const op = hv === null || hv === b.catIdx ? 1 : 0.5;
|
||||
out.push(`<path d="${roundRectPath(b.x, b.y, b.w, b.h, b.round ? BAR_RADIUS : 0, b.side)}" fill="${esc(p.colorOf(b.seriesIdx))}" fill-opacity="${op}"/>`);
|
||||
}
|
||||
}
|
||||
|
||||
// areas then lines (3D does not apply — depth reads as noise on a line).
|
||||
for (const pth of paths()) {
|
||||
if (p.props.kind === "area") out.push(`<path d="${pth.area}" fill="${esc(p.colorOf(pth.seriesIdx))}" fill-opacity="0.1"/>`);
|
||||
}
|
||||
@@ -411,11 +487,15 @@ function CartesianChart(p: SubProps): JSXElement {
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (p.props.tooltip === false) return;
|
||||
const rect = (e.currentTarget as SVGElement).getBoundingClientRect();
|
||||
const idx = clamp(Math.floor((e.clientX - rect.left - layout().left) / bandW()), 0, Math.max(0, n() - 1));
|
||||
setHover(idx);
|
||||
setPointerY(e.clientY - rect.top);
|
||||
const px = e.clientX - rect.left, py = e.clientY - rect.top;
|
||||
const along = horiz() ? py - layout().top : px - layout().left;
|
||||
setHover(clamp(Math.floor(along / bandFull()), 0, Math.max(0, n() - 1)));
|
||||
setPointer([px, py]);
|
||||
};
|
||||
|
||||
const anchorX = () => (horiz() ? pointer()[0] : catCenter(hover()!));
|
||||
const anchorY = () => (horiz() ? catCenter(hover()!) : pointer()[1]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<svg width={p.width} height={p.height} viewBox={`0 0 ${p.width} ${p.height}`} class="block overflow-visible"
|
||||
@@ -424,14 +504,15 @@ function CartesianChart(p: SubProps): JSXElement {
|
||||
<CartesianTooltip
|
||||
props={p.props} colorOf={p.colorOf} fmt={p.fmt}
|
||||
index={hover()!} label={labels()[hover()!] ?? ""}
|
||||
anchorX={bandCenter(hover()!)} anchorY={pointerY()} width={p.width} height={p.height} />
|
||||
anchorX={anchorX()} anchorY={anchorY()} width={p.width} height={p.height} />
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface BarMark { x: number; w: number; yBase: number; yVal: number; seriesIdx: number; catIdx: number; value: number; round: boolean; }
|
||||
interface LineMark { seriesIdx: number; line: string; area: string; pts: [number, number][]; }
|
||||
function barSide(horiz: boolean, v: number): BarSide {
|
||||
return horiz ? (v >= 0 ? "right" : "left") : (v >= 0 ? "top" : "bottom");
|
||||
}
|
||||
|
||||
function CartesianTooltip(p: {
|
||||
props: ChartProps; colorOf: (i: number) => string; fmt: (v: number) => string;
|
||||
@@ -466,19 +547,26 @@ function RadialChart(p: SubProps): JSXElement {
|
||||
const [hover, setHover] = createSignal<number | null>(null);
|
||||
const [pointer, setPointer] = createSignal<[number, number]>([0, 0]);
|
||||
|
||||
const threeD = () => !!p.props.threeD;
|
||||
const depth = () => p.props.depth ?? DEFAULT_DEPTH;
|
||||
const tilt = () => (threeD() ? 0.62 : 1); // vertical squash of the disc when tilted
|
||||
|
||||
const values = () => p.props.series[0]?.data ?? [];
|
||||
const labels = () => p.props.labels ?? values().map((_, i) => String(i + 1));
|
||||
const total = () => values().reduce((a, v) => a + Math.max(0, v), 0);
|
||||
|
||||
const geo = () => {
|
||||
const cx = p.width / 2, cy = p.height / 2;
|
||||
const rOut = Math.max(0, Math.min(p.width, p.height) / 2 - 8);
|
||||
const k = tilt();
|
||||
const cx = p.width / 2;
|
||||
// tilting shrinks the disc's height to k·2r and adds `depth` below; keep it centred.
|
||||
const rOut = Math.max(0, Math.min(p.width, p.height - (threeD() ? depth() : 0)) / 2 - 8);
|
||||
const cy = p.height / 2 - (threeD() ? depth() / 2 : 0);
|
||||
const rIn = p.props.kind === "donut" ? rOut * (p.props.donutRatio ?? 0.6) : 0;
|
||||
return { cx, cy, rOut, rIn };
|
||||
return { cx, cy, rOut, rIn, k };
|
||||
};
|
||||
|
||||
// Slices with their angular spans. A lone value becomes a full ring (drawn as a
|
||||
// circle, since an arc from 0° to 360° collapses).
|
||||
// circle/ellipse, since an arc from 0° to 360° collapses).
|
||||
const slices = createMemo(() => {
|
||||
const t = total();
|
||||
const out: { idx: number; a0: number; a1: number; value: number }[] = [];
|
||||
@@ -491,20 +579,44 @@ function RadialChart(p: SubProps): JSXElement {
|
||||
return out;
|
||||
});
|
||||
|
||||
// The extruded rim under one slice: the front-facing part of its outer arc (angles
|
||||
// 90°–270°, where the ellipse edge dips below centre) swept down by `depth`.
|
||||
const wall = (g: ReturnType<typeof geo>, a0: number, a1: number): string => {
|
||||
const w0 = Math.max(a0, 90), w1 = Math.min(a1, 270);
|
||||
if (w1 <= w0) return "";
|
||||
const [x0, y0] = tiltPoint(g.cx, g.cy, g.rOut, w0, g.k);
|
||||
const [x1, y1] = tiltPoint(g.cx, g.cy, g.rOut, w1, g.k);
|
||||
const large = w1 - w0 > 180 ? 1 : 0;
|
||||
return `M${x0},${y0} A${g.rOut},${g.k * g.rOut} 0 ${large} 1 ${x1},${y1}` +
|
||||
` L${x1},${y1 + depth()} A${g.rOut},${g.k * g.rOut} 0 ${large} 0 ${x0},${y0 + depth()} Z`;
|
||||
};
|
||||
|
||||
const body = createMemo(() => {
|
||||
const g = geo(), hv = hover();
|
||||
const out: string[] = [];
|
||||
const positive = slices().filter((s) => s.value > 0);
|
||||
if (positive.length === 1) {
|
||||
// one value: a full ring, since a 360° arc collapses to nothing
|
||||
const single = positive.length === 1;
|
||||
|
||||
// 3D: draw every slice's rim first (the disc's thickness), then the top faces on top.
|
||||
if (threeD()) {
|
||||
for (const s of single ? positive : slices()) {
|
||||
if (s.a1 <= s.a0) continue;
|
||||
const d = single ? wall(g, 90, 270) : wall(g, s.a0, s.a1);
|
||||
if (!d) continue;
|
||||
const c = esc(p.colorOf(s.idx));
|
||||
out.push(`<path d="${d}" fill="${c}"/><path d="${d}" fill="#000" fill-opacity="0.3"/>`);
|
||||
}
|
||||
}
|
||||
|
||||
if (single) {
|
||||
const s = positive[0];
|
||||
out.push(`<circle cx="${g.cx}" cy="${g.cy}" r="${g.rOut}" fill="${esc(p.colorOf(s.idx))}"/>`);
|
||||
if (g.rIn > 0) out.push(`<circle cx="${g.cx}" cy="${g.cy}" r="${g.rIn}" fill="var(--color-surface)"/>`);
|
||||
out.push(`<path d="${slicePathD(g.cx, g.cy, g.rOut, 0, 0, 359.999, g.k)}" fill="${esc(p.colorOf(s.idx))}"/>`);
|
||||
if (g.rIn > 0) out.push(`<ellipse cx="${g.cx}" cy="${g.cy}" rx="${g.rIn}" ry="${g.k * g.rIn}" fill="var(--color-surface)"/>`);
|
||||
} else {
|
||||
for (const s of slices()) {
|
||||
if (s.a1 <= s.a0) continue;
|
||||
const op = hv === null || hv === s.idx ? 1 : 0.55;
|
||||
out.push(`<path d="${slicePathD(g.cx, g.cy, g.rOut, g.rIn, s.a0, s.a1)}" fill="${esc(p.colorOf(s.idx))}" fill-opacity="${op}" stroke="var(--color-surface)" stroke-width="${SEG_GAP}"/>`);
|
||||
out.push(`<path d="${slicePathD(g.cx, g.cy, g.rOut, g.rIn, s.a0, s.a1, g.k)}" fill="${esc(p.colorOf(s.idx))}" fill-opacity="${op}" stroke="var(--color-surface)" stroke-width="${SEG_GAP}"/>`);
|
||||
}
|
||||
}
|
||||
return out.join("");
|
||||
@@ -515,11 +627,9 @@ function RadialChart(p: SubProps): JSXElement {
|
||||
const rect = (e.currentTarget as SVGElement).getBoundingClientRect();
|
||||
const g = geo();
|
||||
const px = e.clientX - rect.left, py = e.clientY - rect.top;
|
||||
const dx = px - g.cx, dy = py - g.cy;
|
||||
const dx = px - g.cx, dy = (py - g.cy) / g.k; // undo the tilt to test against a circle
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist > g.rOut || (g.rIn > 0 && dist < g.rIn)) { setHover(null); return; }
|
||||
// pointOnCircle maps deg (from 12 o'clock, clockwise) to (cos(deg-90), sin(deg-90));
|
||||
// invert it: the pointer's slice angle is atan2(dy,dx) shifted back by 90°.
|
||||
let deg = (Math.atan2(dy, dx) * 180 / Math.PI + 90 + 360) % 360;
|
||||
const s = slices().find((s) => s.value > 0 && deg >= s.a0 && deg < s.a1);
|
||||
setHover(s ? s.idx : null);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// the kit are none the wiser.
|
||||
//
|
||||
// The storage key is deliberately the SAME one the Go/WASM kit uses
|
||||
// (webui.ThemeBootScript, webui.themeStorageKey). Both layers of kjol-web are served
|
||||
// (webui.ThemeBootScript, webui.themeStorageKey). Both layers of kjol-website are served
|
||||
// from one origin, so they share a localStorage: choose dark in the /wasm section,
|
||||
// walk over to /js, and it is still dark. Two front-ends, one preference.
|
||||
|
||||
|
||||
234
go/jsruntime/uikit/USHeatmap.tsx
Normal file
234
go/jsruntime/uikit/USHeatmap.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { createMemo, createSignal, For, Show, JSXElement } from "solid-js";
|
||||
import { US_STATES, US_VIEWBOX } from "./usStates.ts";
|
||||
|
||||
// A choropleth of the 50 states + DC, plus optional lat/lng markers. Like uikit/Chart it
|
||||
// draws SVG as an innerHTML string (the Go Solid compiler will not namespace control-flow
|
||||
// SVG — see Chart.tsx). The state boundaries are pre-projected with d3's albersUsa into a
|
||||
// 960×600 box (usStates.ts); the SAME projection is reimplemented below so that lat/lng
|
||||
// points land exactly on top of the states. It is a faithful, dependency-free port —
|
||||
// validated to 0px against d3-geo, Alaska and Hawaii insets included.
|
||||
//
|
||||
// The map scales by viewBox rather than by measurement (there is no axis text to keep
|
||||
// crisp), so no ResizeObserver: viewBox 960×600 + a 960/600 aspect-ratio box fills the
|
||||
// column. Hover reads e.target's data-attributes — the specific state path or point marker
|
||||
// under the pointer — so no coordinate maths is needed to know what is being pointed at.
|
||||
|
||||
// ── the albersUsa projection (ported from d3-geo, scale 1280, translate [480,300]) ──────
|
||||
|
||||
const RAD = Math.PI / 180, TAU = 2 * Math.PI;
|
||||
function conicEqualAreaRaw(y0: number, y1: number) {
|
||||
const sy0 = Math.sin(y0), n = (sy0 + Math.sin(y1)) / 2;
|
||||
const c = 1 + sy0 * (2 * n - sy0), r0 = Math.sqrt(c) / n;
|
||||
return (lambda: number, phi: number): [number, number] => {
|
||||
const r = Math.sqrt(c - 2 * n * Math.sin(phi)) / n;
|
||||
return [r * Math.sin(lambda * n), r0 - r * Math.cos(lambda * n)];
|
||||
};
|
||||
}
|
||||
// One conic-equal-area lobe. `center` is given in the rotated frame (near 0° lon), so it is
|
||||
// not re-rotated; the input point is rotated by `rotateLon` before projecting.
|
||||
function albersLobe(rotateLon: number, centerLon: number, centerLat: number, p0: number, p1: number, scale: number, tx: number, ty: number) {
|
||||
const raw = conicEqualAreaRaw(p0 * RAD, p1 * RAD);
|
||||
const rot = (lon: number) => { const l = (lon + rotateLon) * RAD; return ((l + Math.PI) % TAU + TAU) % TAU - Math.PI; };
|
||||
const [cx, cy] = raw(centerLon * RAD, centerLat * RAD);
|
||||
return (lon: number, lat: number): [number, number] => {
|
||||
const [x, y] = raw(rot(lon), lat * RAD);
|
||||
return [tx + scale * (x - cx), ty - scale * (y - cy)];
|
||||
};
|
||||
}
|
||||
const K = 1280, TX = 480, TY = 300, EPS = 1e-6;
|
||||
const _lower48 = albersLobe(96, -0.6, 38.7, 29.5, 45.5, K, TX, TY);
|
||||
const _alaska = albersLobe(154, -2, 58.5, 55, 65, K * 0.35, TX - 0.307 * K, TY + 0.201 * K);
|
||||
const _hawaii = albersLobe(157, -3, 19.9, 8, 18, K, TX - 0.205 * K, TY + 0.212 * K);
|
||||
const inBox = (p: [number, number], x0: number, y0: number, x1: number, y1: number) =>
|
||||
p[0] >= x0 && p[0] <= x1 && p[1] >= y0 && p[1] <= y1;
|
||||
|
||||
// Project [lng, lat] to the 960×600 map, choosing the lower-48 / Alaska / Hawaii lobe the
|
||||
// way albersUsa does — by which one's clip box the point falls in. null if off-map.
|
||||
export function projectUS(lng: number, lat: number): [number, number] | null {
|
||||
let p = _lower48(lng, lat);
|
||||
if (inBox(p, TX - 0.455 * K, TY - 0.238 * K, TX + 0.455 * K, TY + 0.238 * K)) return p;
|
||||
p = _alaska(lng, lat);
|
||||
if (inBox(p, TX - 0.425 * K + EPS, TY + 0.120 * K + EPS, TX - 0.214 * K - EPS, TY + 0.234 * K - EPS)) return p;
|
||||
p = _hawaii(lng, lat);
|
||||
if (inBox(p, TX - 0.214 * K + EPS, TY + 0.166 * K + EPS, TX - 0.115 * K - EPS, TY + 0.234 * K - EPS)) return p;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── the component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface USHeatmapPoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
value?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface USHeatmapProps {
|
||||
// State value map: USPS code (e.g. "CA", "TX", "DC") → number. States present are
|
||||
// shaded on the sequential ramp; states absent are drawn in the no-data neutral.
|
||||
data?: Record<string, number>;
|
||||
// lat/lng markers, projected onto the map. Points outside the US are dropped.
|
||||
points?: USHeatmapPoint[];
|
||||
|
||||
height?: number; // px; omit to size from the container width (960:600 aspect).
|
||||
class?: string;
|
||||
steps?: number; // choropleth buckets, 1–6 (default 6, the token count).
|
||||
tooltip?: boolean;
|
||||
valueFormat?: (v: number) => string;
|
||||
pointColor?: string; // default var(--color-chart-1) (blue).
|
||||
pointRadius?: number; // fixed dot radius (default 5); the MAXIMUM radius when proportional.
|
||||
// Scale each dot's AREA by its value (radius ∝ √value) so a bigger dot means "more" —
|
||||
// area, not radius, because the eye reads a circle by its area.
|
||||
proportional?: boolean;
|
||||
// Override a state's tooltip name (default the built-in full name).
|
||||
stateName?: (code: string) => string;
|
||||
}
|
||||
|
||||
const CHOROPLETH_STEPS = 6; // must match --color-choropleth-1..N in theme.css
|
||||
const POINT_OPACITY = 0.85; // dots are slightly see-through so the state beneath still reads
|
||||
const _intl = () => new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
|
||||
let _fmt: Intl.NumberFormat | null = null;
|
||||
const defaultFormat = (v: number) => (Number.isFinite(v) ? (_fmt ??= _intl()).format(v) : String(v));
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
||||
const esc = (s: unknown) =>
|
||||
String(s).replace(/[&<>"]/g, (c) => (c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """));
|
||||
|
||||
type Hover =
|
||||
| { kind: "state"; code: string }
|
||||
| { kind: "point"; idx: number }
|
||||
| null;
|
||||
|
||||
export function USHeatmap(props: USHeatmapProps): JSXElement {
|
||||
let wrap: HTMLDivElement | undefined;
|
||||
const [hover, setHover] = createSignal<Hover>(null);
|
||||
const [pointer, setPointer] = createSignal<[number, number]>([0, 0]);
|
||||
|
||||
const fmt = (v: number) => (props.valueFormat ?? defaultFormat)(v);
|
||||
const steps = () => clamp(props.steps ?? CHOROPLETH_STEPS, 1, CHOROPLETH_STEPS);
|
||||
const stateName = (code: string) => (props.stateName ? props.stateName(code) : US_STATES[code]?.name ?? code);
|
||||
|
||||
// The value range across the states that have data, for quantising into ramp buckets.
|
||||
const range = createMemo(() => {
|
||||
const vals = Object.values(props.data ?? {}).filter((v) => Number.isFinite(v));
|
||||
return vals.length ? { min: Math.min(...vals), max: Math.max(...vals), has: true } : { min: 0, max: 0, has: false };
|
||||
});
|
||||
const bucket = (v: number) => {
|
||||
const r = range();
|
||||
const t = r.max > r.min ? (v - r.min) / (r.max - r.min) : 1;
|
||||
return clamp(Math.floor(t * steps()), 0, steps() - 1) + 1;
|
||||
};
|
||||
|
||||
// projected markers (drop anything off-map), kept with their original index for hover.
|
||||
const points = createMemo(() =>
|
||||
(props.points ?? []).map((pt, idx) => ({ pt, idx, xy: projectUS(pt.lng, pt.lat) }))
|
||||
.filter((m): m is { pt: USHeatmapPoint; idx: number; xy: [number, number] } => m.xy !== null));
|
||||
|
||||
const body = createMemo(() => {
|
||||
const data = props.data ?? {};
|
||||
const hv = hover();
|
||||
const proportional = !!props.proportional;
|
||||
const maxR = props.pointRadius ?? (proportional ? 16 : 5);
|
||||
const minR = Math.min(3, maxR * 0.35);
|
||||
const maxV = proportional ? Math.max(1, ...points().map((m) => Math.max(0, m.pt.value ?? 0))) : 1;
|
||||
const radiusOf = (v: number | undefined) =>
|
||||
proportional ? minR + (maxR - minR) * Math.sqrt(clamp((v ?? 0) / maxV, 0, 1)) : maxR;
|
||||
const pc = esc(props.pointColor ?? "var(--color-chart-1)");
|
||||
const out: string[] = [];
|
||||
|
||||
for (const code in US_STATES) {
|
||||
const st = US_STATES[code];
|
||||
const has = Object.prototype.hasOwnProperty.call(data, code) && Number.isFinite(data[code]);
|
||||
const fill = has ? `var(--color-choropleth-${bucket(data[code])})` : "var(--color-surface-strong)";
|
||||
const isHover = hv?.kind === "state" && hv.code === code;
|
||||
out.push(`<path d="${st.d}" data-state="${code}" fill="${fill}" fill-opacity="${isHover ? 0.82 : 1}" stroke="var(--color-surface)" stroke-width="0.8"/>`);
|
||||
}
|
||||
|
||||
for (const m of points()) {
|
||||
const isHover = hv?.kind === "point" && hv.idx === m.idx;
|
||||
const r = radiusOf(m.pt.value);
|
||||
out.push(`<circle data-pt="${m.idx}" cx="${m.xy[0]}" cy="${m.xy[1]}" r="${isHover ? r + 2 : r}" fill="${pc}" fill-opacity="${POINT_OPACITY}"/>`);
|
||||
}
|
||||
return out.join("");
|
||||
});
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (props.tooltip === false) return;
|
||||
const t = e.target as Element;
|
||||
const pIdx = t.getAttribute?.("data-pt");
|
||||
const code = t.getAttribute?.("data-state");
|
||||
if (pIdx != null) setHover({ kind: "point", idx: +pIdx });
|
||||
else if (code != null) setHover({ kind: "state", code });
|
||||
else setHover(null);
|
||||
if (wrap) {
|
||||
const r = wrap.getBoundingClientRect();
|
||||
setPointer([e.clientX - r.left, e.clientY - r.top]);
|
||||
}
|
||||
};
|
||||
|
||||
const tip = createMemo(() => {
|
||||
const hv = hover();
|
||||
if (!hv) return null;
|
||||
if (hv.kind === "point") {
|
||||
const pt = (props.points ?? [])[hv.idx];
|
||||
if (!pt) return null;
|
||||
return {
|
||||
title: pt.label ?? `${pt.lat.toFixed(2)}, ${pt.lng.toFixed(2)}`,
|
||||
value: pt.value != null ? fmt(pt.value) : "",
|
||||
swatch: props.pointColor ?? "var(--color-chart-1)",
|
||||
};
|
||||
}
|
||||
const v = (props.data ?? {})[hv.code];
|
||||
const has = v != null && Number.isFinite(v);
|
||||
return {
|
||||
title: stateName(hv.code),
|
||||
value: has ? fmt(v) : "no data",
|
||||
swatch: has ? `var(--color-choropleth-${bucket(v)})` : "var(--color-surface-strong)",
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={wrap} class={"relative w-full" + (props.class ? " " + props.class : "")}>
|
||||
<svg viewBox={US_VIEWBOX} role="img" class="block w-full"
|
||||
style={props.height ? { height: `${props.height}px` } : { "aspect-ratio": "960 / 600" }}
|
||||
innerHTML={body()} onpointermove={onMove} onpointerleave={() => setHover(null)} />
|
||||
|
||||
<Show when={props.tooltip !== false && tip()}>
|
||||
{(t) => (
|
||||
<div class="pointer-events-none absolute z-10 min-w-28 max-w-64 rounded-default border border-line bg-surface px-3 py-2 text-xs shadow-lg"
|
||||
style={{
|
||||
left: `${clamp(pointer()[0], 8, 100000)}px`,
|
||||
top: `${Math.max(8, pointer()[1])}px`,
|
||||
transform: "translate(-50%, calc(-100% - 12px))",
|
||||
}}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-block h-2.5 w-2.5 shrink-0 rounded-xs" style={{ "background-color": t().swatch }} />
|
||||
<span class="font-medium text-ink">{t().title}</span>
|
||||
</div>
|
||||
<Show when={t().value}>
|
||||
<div class="mt-1 font-semibold text-ink" style={{ "font-variant-numeric": "tabular-nums" }}>{t().value}</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={range().has}>
|
||||
<ChoroplethLegend min={range().min} max={range().max} steps={steps()} fmt={fmt} />
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoroplethLegend(p: { min: number; max: number; steps: number; fmt: (v: number) => string }): JSXElement {
|
||||
const swatches = () => Array.from({ length: p.steps }, (_, i) => i + 1);
|
||||
return (
|
||||
<div class="mt-3 flex items-center gap-2 text-xs text-ink-muted">
|
||||
<span style={{ "font-variant-numeric": "tabular-nums" }}>{p.fmt(p.min)}</span>
|
||||
<div class="flex overflow-hidden rounded-xs">
|
||||
<For each={swatches()}>{(k) => (
|
||||
<span class="h-3 w-6" style={{ "background-color": `var(--color-choropleth-${k})` }} />
|
||||
)}</For>
|
||||
</div>
|
||||
<span style={{ "font-variant-numeric": "tabular-nums" }}>{p.fmt(p.max)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
go/jsruntime/uikit/usStates.ts
Normal file
56
go/jsruntime/uikit/usStates.ts
Normal file
File diff suppressed because one or more lines are too long
@@ -20,7 +20,7 @@
|
||||
//
|
||||
// The one thing tying it to a UI is the palette — the class names below are Tailwind's, so
|
||||
// whatever compiles your CSS has to scan THIS package too, or the colours will not exist.
|
||||
// (In kjøl's own site that is cmd/kjol-web/build.Tailwind's source globs.)
|
||||
// (In kjøl's own site that is cmd/kjol-website/build.Tailwind's source globs.)
|
||||
package lexer
|
||||
|
||||
import (
|
||||
|
||||
@@ -12,7 +12,7 @@ ui.Alert(ui.AlertGreen, "Done", vdom.Text("Saved."))
|
||||
ui.ToggleSwitch(on, func(v bool){ on = v }, "Notifications", "", false, "")
|
||||
```
|
||||
|
||||
See the runnable **`/wasm/kit`** demo page in `cmd/kjol-web` (Layers → Kjol Wasm Web).
|
||||
See the runnable **`/wasm/kit`** demo page in `cmd/kjol-website` (Layers → Kjol Wasm Web).
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
Reference in New Issue
Block a user