Update 3d chart mode, add US heatmap, move kjol-web -> kjol-website

This commit is contained in:
2026-07-16 12:40:49 -04:00
parent 550e97aa9b
commit 2477c2d6a2
75 changed files with 701 additions and 416 deletions

18
.gitignore vendored
View File

@@ -84,20 +84,20 @@ wwwroot/public.bundle.min.js
internal/handlers/public_pages.gen.go 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 # 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 # 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 # 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. # Everything here is regenerated by `go run ./build`; none of it is authored.
go/cmd/kjol-web/wwwroot/bundle.min.* go/cmd/kjol-website/wwwroot/bundle.min.*
go/cmd/kjol-web/wwwroot/public.bundle.min.* go/cmd/kjol-website/wwwroot/public.bundle.min.*
go/cmd/kjol-web/wwwroot/app.css go/cmd/kjol-website/wwwroot/app.css
go/cmd/kjol-web/wwwroot/wasm_exec.js go/cmd/kjol-website/wwwroot/wasm_exec.js
go/cmd/kjol-web/wwwroot/vendor/ go/cmd/kjol-website/wwwroot/vendor/
go/cmd/kjol-web/frontend/src/ui/generated/ go/cmd/kjol-website/frontend/src/ui/generated/
go/cmd/kjol-web/internal/handlers/public_pages.gen.go go/cmd/kjol-website/internal/handlers/public_pages.gen.go
# kjol framework (git submodule; deny-list above would otherwise ignore it) # kjol framework (git submodule; deny-list above would otherwise ignore it)
!/kjol !/kjol

40
.vscode/launch.json vendored
View File

@@ -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 // resolves ./wwwroot, ./app, ./wasm, ./frontend and the watched engine dirs relative
// to it. // 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 // 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 // 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. // 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 // => ../..). 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 // kjolwebsite — 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. // .../kjol-website/server". Off, Go uses kjolwebsite's own go.mod; standalone it is a no-op.
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "kjol-web: dev server", "name": "kjol-website: dev server",
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
"program": "${workspaceFolder}/go/cmd/kjol-web/server", "program": "${workspaceFolder}/go/cmd/kjol-website/server",
"cwd": "${workspaceFolder}/go/cmd/kjol-web", "cwd": "${workspaceFolder}/go/cmd/kjol-website",
"env": { "GOWORK": "off" }, "env": { "GOWORK": "off" },
"args": ["-addr", ":8085"], "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", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
"program": "${workspaceFolder}/go/cmd/kjol-web/server", "program": "${workspaceFolder}/go/cmd/kjol-website/server",
"cwd": "${workspaceFolder}/go/cmd/kjol-web", "cwd": "${workspaceFolder}/go/cmd/kjol-website",
"env": { "GOWORK": "off" }, "env": { "GOWORK": "off" },
// -watch=false skips the server's own Build entirely, so wwwroot is served exactly // -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 // 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 // bundles there; without it you would be debugging against stale artefacts — and
// with no bundle at all, every /js/* page would come up blank. // with no bundle at all, every /js/* page would come up blank.
"args": ["-watch=false"], "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", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
// The same binary, with the flag that runs every build step once and exits. There is // 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 // no separate ./build command: the steps are a library, because the server imports
// them and Go will not let you import a main. // them and Go will not let you import a main.
"program": "${workspaceFolder}/go/cmd/kjol-web/server", "program": "${workspaceFolder}/go/cmd/kjol-website/server",
"cwd": "${workspaceFolder}/go/cmd/kjol-web", "cwd": "${workspaceFolder}/go/cmd/kjol-website",
"env": { "GOWORK": "off" }, "env": { "GOWORK": "off" },
"args": ["-build"] "args": ["-build"]
}, },
{ {
"name": "kjol-web: codegen (wasmgen)", "name": "kjol-website: codegen (wasmgen)",
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
"program": "${workspaceFolder}/go/cmd/wasmgen", "program": "${workspaceFolder}/go/cmd/wasmgen",
"cwd": "${workspaceFolder}/go/cmd/kjol-web", "cwd": "${workspaceFolder}/go/cmd/kjol-website",
"env": { "GOWORK": "off" }, "env": { "GOWORK": "off" },
"args": ["./app"] "args": ["./app"]
}, },
{ {
"name": "kjol-web: bundle (Solid)", "name": "kjol-website: bundle (Solid)",
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
// Debug the JS build itself — the Solid compiler, the Tailwind engine, the SSR bake. // 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. // All of it is Go, so all of it takes a breakpoint.
"program": "${workspaceFolder}/go/cmd/bundle", "program": "${workspaceFolder}/go/cmd/bundle",
"cwd": "${workspaceFolder}/go/cmd/kjol-web", "cwd": "${workspaceFolder}/go/cmd/kjol-website",
"env": { "GOWORK": "off" }, "env": { "GOWORK": "off" },
"args": [ "args": [
"-app", "frontend", "-app", "frontend",

56
.vscode/tasks.json vendored
View File

@@ -1,66 +1,66 @@
{ {
// Tasks for the kjol repo. // Tasks for the kjol repo.
// //
// kjol-web — the website, and the runnable example of both web layers — is a nested // kjol-website — 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 // module at go/cmd/kjol-website, so its tasks set cwd there; the module-wide Go tasks run
// in go/. // in go/.
// //
// These used to be called "gowasm: ...", which stopped being true: the site is two // 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. // 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 // 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 // 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 // library (the server imports them, and Go will not let you import a main), so a cold
// build is `go run ./server -build`. // 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 // 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, // 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 // 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 // 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. // 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 // The kjol-website tasks (cwd go/cmd/kjol-website) set GOWORK=off: kjol-website is its own module
// (module kjolweb, replace kjol => ../..), and inside a parent project the parent's // (module kjolwebsite, 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 // go.work — which does not list kjolwebsite — would shadow it and the go command would fail
// "main module (...) does not contain package .../kjol-web/...". The kjol-module tasks // "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. // (cwd go) keep the workspace, since kjol/go is what a parent's go.work does list.
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
"label": "kjol-web: prebuild", "label": "kjol-website: prebuild",
"detail": "Codegen -> Tailwind -> Solid bundle. Dependency of the run + debug configs.", "detail": "Codegen -> Tailwind -> Solid bundle. Dependency of the run + debug configs.",
"dependsOrder": "sequence", "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": [], "problemMatcher": [],
"group": "build" "group": "build"
}, },
{ {
"label": "kjol-web: codegen", "label": "kjol-website: codegen",
"detail": "Regenerate app/*.gen.go from the //gowasm: directives (pages, layouts, server components)", "detail": "Regenerate app/*.gen.go from the //gowasm: directives (pages, layouts, server components)",
"type": "shell", "type": "shell",
"command": "go", "command": "go",
"args": ["run", "kjol/cmd/wasmgen", "./app"], "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"], "problemMatcher": ["$go"],
"presentation": { "reveal": "silent", "panel": "shared" }, "presentation": { "reveal": "silent", "panel": "shared" },
"group": "build" "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", "detail": "Compile css/app.css -> wwwroot/app.css, scanning the webui kit + the site's Go markup",
"type": "shell", "type": "shell",
"command": "go", "command": "go",
"args": [ "args": [
"run", "./cmd/twcss", "run", "./cmd/twcss",
"-entry", "cmd/kjol-web/css/app.css", "-entry", "cmd/kjol-website/css/app.css",
"-out", "cmd/kjol-web/wwwroot/app.css", "-out", "cmd/kjol-website/wwwroot/app.css",
"-base", ".", "-base", ".",
"webui/**/*.go", "webui/**/*.go",
"cmd/kjol-web/app/**/*.go", "cmd/kjol-website/app/**/*.go",
"cmd/kjol-web/server/**/*.go" "cmd/kjol-website/server/**/*.go"
], ],
// Run from the kjol module root so the Tailwind engine's deps resolve in kjol's // Run from the kjol module root so the Tailwind engine's deps resolve in kjol's
// go.mod, not the site's. // go.mod, not the site's.
@@ -70,7 +70,7 @@
"group": "build" "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.*", "detail": "TSX -> Solid -> esbuild, plus the SSR bake: wwwroot/bundle.min.{js,css} + public.bundle.min.*",
"type": "shell", "type": "shell",
"command": "go", "command": "go",
@@ -83,19 +83,19 @@
"-out", "wwwroot", "-out", "wwwroot",
"-gen-ts", "frontend/src/ui/generated" "-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"], "problemMatcher": ["$go"],
"presentation": { "reveal": "silent", "panel": "shared" }, "presentation": { "reveal": "silent", "panel": "shared" },
"group": "build" "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.", "detail": "SSR + /rsc + hot reload on :8085. Rebuilds on save; a .css save recompiles Tailwind only and swaps the stylesheet in place.",
"type": "shell", "type": "shell",
"command": "go", "command": "go",
"args": ["run", "./server"], "args": ["run", "./server"],
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web", "env": { "GOWORK": "off" } }, "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-website", "env": { "GOWORK": "off" } },
"dependsOn": ["kjol-web: prebuild"], "dependsOn": ["kjol-website: prebuild"],
"isBackground": true, "isBackground": true,
"problemMatcher": { "problemMatcher": {
"owner": "go", "owner": "go",
@@ -113,18 +113,18 @@
"group": { "kind": "build", "isDefault": true } "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.", "detail": "Cold build, then exit: codegen + Tailwind + wasm + Solid bundle + wasm_exec.js.",
"type": "shell", "type": "shell",
"command": "go", "command": "go",
"args": ["run", "./server", "-build"], "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"], "problemMatcher": ["$go"],
"group": "build" "group": "build"
}, },
{ {
"label": "kjol: 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", "type": "shell",
"command": "go build ./...", "command": "go build ./...",
"options": { "cwd": "${workspaceFolder}/go" }, "options": { "cwd": "${workspaceFolder}/go" },
@@ -161,11 +161,11 @@
"group": "test" "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.", "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", "type": "shell",
"command": "go test ./...", "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"], "problemMatcher": ["$go"],
"group": "test" "group": "test"
} }

View File

@@ -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 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 `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 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. 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 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 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 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. 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 - `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 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`. a fuller kit keeps it — see `jsbundler.iconsDirs`.
- `styles/theme.css` — the `@theme` scaffold, the semantic tokens, the `.dark` overrides, and the - `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 `: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 | | `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 | | `httputil.CorsMiddleware(CorsConfig{...})` | allowed domains + bundle-version source injected |
| `wasmdevserver` | the app injects `Build` / `Render` / `Document` / `Handle` via `Config` | | `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) ## Stays app-side (never moves into kjol)

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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 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 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 ## Run it
```sh ```sh
cd go/cmd/kjol-web cd go/cmd/kjol-website
go run ./server -build # cold build, then exit go run ./server -build # cold build, then exit
go run ./server # build, then SSR + /rsc + hot reload at http://localhost:8085 go run ./server # build, then SSR + /rsc + hot reload at http://localhost:8085
``` ```
@@ -121,7 +121,7 @@ one way.
## Its own module ## 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 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 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. does not descend into this nested module; build it from here.

View File

@@ -618,7 +618,7 @@ func tablesSection() func() *VNode {
} }
return ui.AutoTablePDFHeader{ return ui.AutoTablePDFHeader{
Title: "Employees", Title: "Employees",
Subtitle: "Exported from the kjol-web components page", Subtitle: "Exported from the kjol-website components page",
ShowDate: true, ShowDate: true,
Orientation: orientation, Orientation: orientation,
} }

View File

@@ -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 // standalone, platform-neutral functions (SSR on the server, hydrate on the
// client). UI is built from the kjol webui kit + Tailwind utility classes. // client). UI is built from the kjol webui kit + Tailwind utility classes.
// //

View File

@@ -106,13 +106,13 @@ func Codegen() ([]byte, error) {
// names, and nothing else in the tree mentions text-teal-300. // names, and nothing else in the tree mentions text-teal-300.
func Tailwind() ([]byte, error) { func Tailwind() ([]byte, error) {
cmd := exec.Command("go", "run", "./cmd/twcss", cmd := exec.Command("go", "run", "./cmd/twcss",
"-entry", "cmd/kjol-web/css/app.css", "-entry", "cmd/kjol-website/css/app.css",
"-out", "cmd/kjol-web/wwwroot/app.css", "-out", "cmd/kjol-website/wwwroot/app.css",
"-base", ".", "-base", ".",
"webui/**/*.go", "webui/**/*.go",
"lexer/**/*.go", "lexer/**/*.go",
"cmd/kjol-web/app/**/*.go", "cmd/kjol-website/app/**/*.go",
"cmd/kjol-web/server/**/*.go", "cmd/kjol-website/server/**/*.go",
) )
cmd.Dir = kjolRoot cmd.Dir = kjolRoot
return cmd.CombinedOutput() return cmd.CombinedOutput()

View File

@@ -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 There is deliberately no `@import "tailwindcss"` here. The bundler PREPENDS
kjol's shared scaffold (go/jsruntime/styles/theme.css) to this file, and that kjol's shared scaffold (go/jsruntime/styles/theme.css) to this file, and that

View File

@@ -84,6 +84,7 @@ import { Accordion, SingleAccordion } from "@ui/Accordion";
import { SidebarNav } from "@ui/Sidebar"; import { SidebarNav } from "@ui/Sidebar";
import { FuzzyMatch } from "@ui/FuzzyMatch"; import { FuzzyMatch } from "@ui/FuzzyMatch";
import { Chart, ChartSeries } from "@ui/Chart"; import { Chart, ChartSeries } from "@ui/Chart";
import { USHeatmap } from "@ui/USHeatmap";
import { ThemeToggle, useTheme } from "@ui/Theme"; import { ThemeToggle, useTheme } from "@ui/Theme";
import { Demo } from "../layout/Demo.tsx"; import { Demo } from "../layout/Demo.tsx";
@@ -464,7 +465,7 @@ function Forms() {
email() && !isEmailValid(email()) ? "That is not an email address." : ""; email() && !isEmailValid(email()) ? "That is not an email address." : "";
<FormEmailInput <FormEmailInput
value={email} value={email()}
oninput={(e) => setEmail(e.currentTarget.value)} oninput={(e) => setEmail(e.currentTarget.value)}
error={emailError()} error={emailError()}
showIcon showIcon
@@ -474,21 +475,21 @@ function Forms() {
<Field label="Name"> <Field label="Name">
<FormInput <FormInput
placeholder="Ada Lovelace" placeholder="Ada Lovelace"
value={name} value={name()}
oninput={(e) => setName(e.currentTarget.value)} oninput={(e) => setName(e.currentTarget.value)}
/> />
</Field> </Field>
<Field label="Email"> <Field label="Email">
<FormEmailInput <FormEmailInput
placeholder="ada@example.com" placeholder="ada@example.com"
value={email} value={email()}
oninput={(e) => setEmail(e.currentTarget.value)} oninput={(e) => setEmail(e.currentTarget.value)}
error={emailError()} error={emailError()}
showIcon showIcon
/> />
</Field> </Field>
<Field label="Plan"> <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="free">Free</option>
<option value="pro">Pro</option> <option value="pro">Pro</option>
<option value="enterprise">Enterprise</option> <option value="enterprise">Enterprise</option>
@@ -498,7 +499,7 @@ function Forms() {
<FormTextarea <FormTextarea
rows={3} rows={3}
placeholder="Anything worth remembering…" placeholder="Anything worth remembering…"
value={notes} value={notes()}
oninput={(e) => setNotes(e.currentTarget.value)} oninput={(e) => setNotes(e.currentTarget.value)}
/> />
</Field> </Field>
@@ -532,11 +533,11 @@ function Forms() {
<FormFieldset legend="Account"> <FormFieldset legend="Account">
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<Field label="Name"> <Field label="Name">
<FormInput value={name} oninput={(e) => setName(e.currentTarget.value)} /> <FormInput value={name()} oninput={(e) => setName(e.currentTarget.value)} />
</Field> </Field>
<Field label="Email (validated)"> <Field label="Email (validated)">
<FormEmailInput <FormEmailInput
value={email} value={email()}
oninput={(e) => setEmail(e.currentTarget.value)} oninput={(e) => setEmail(e.currentTarget.value)}
error={emailError()} error={emailError()}
showIcon showIcon
@@ -590,7 +591,7 @@ function Selects() {
<Field label="Language (searchable, one)"> <Field label="Language (searchable, one)">
<FormCombobox <FormCombobox
options={LANGUAGES} options={LANGUAGES}
value={one} value={one()}
onchange={setOne} onchange={setOne}
searchable searchable
placeholder="Pick one" placeholder="Pick one"
@@ -607,10 +608,10 @@ function Selects() {
/> />
</Field> </Field>
<Field label="State"> <Field label="State">
<FormCombobox options={US_STATES} value={state} onchange={setState} searchable /> <FormCombobox options={US_STATES} value={state()} onchange={setState} searchable />
</Field> </Field>
<Field label="Timezone"> <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> </Field>
</div> </div>
<p class="mt-3 text-xs text-ink-muted"> <p class="mt-3 text-xs text-ink-muted">
@@ -722,10 +723,10 @@ function Dates() {
<Panel title={"Pickers — picked: " + (date() || "nothing")}> <Panel title={"Pickers — picked: " + (date() || "nothing")}>
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<Field label="Date (portaled; flips near the bottom)"> <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>
<Field label="Date of birth (inline, month/year selects)"> <Field label="Date of birth (inline, month/year selects)">
<DateOfBirthPicker value={dob} onchange={setDob} /> <DateOfBirthPicker value={dob()} onchange={setDob} />
</Field> </Field>
</div> </div>
</Panel> </Panel>
@@ -1125,7 +1126,7 @@ function Overlays() {
return ( return (
<Field label="Name (required to continue)"> <Field label="Name (required to continue)">
<FormInput <FormInput
value={wizardName} value={wizardName()}
placeholder="Ada Lovelace" placeholder="Ada Lovelace"
oninput={(e) => setWizardName(e.currentTarget.value)} oninput={(e) => setWizardName(e.currentTarget.value)}
/> />
@@ -1366,8 +1367,29 @@ function Search() {
const CHART_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; 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() { function Charts() {
const [seed, setSeed] = createSignal(0); 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 // 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. // 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 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 pieData = (): ChartSeries => ({ name: "Traffic", data: shuffle([40, 25, 20, 15, 8]) });
const pieLabels = ["Direct", "Search", "Social", "Email", "Referral"]; const pieLabels = ["Direct", "Search", "Social", "Email", "Referral"];
const regions = ["East", "Central", "Mountain", "Pacific"];
return ( return (
<Section id="charts" title="Charts"> <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"> <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="grid gap-6 lg:grid-cols-12">
<div class="lg:col-span-7"> <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>
<div class="lg:col-span-5"> <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>
<div class="lg:col-span-7"> <div class="lg:col-span-7">
<Chart kind="area" curve="smooth" labels={CHART_DAYS} series={[requests()]} height={220} /> <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)}> <ButtonUI color={BUTTON_COLOR_PRIMARY} small onclick={() => setSeed(seed() + 1)}>
New data New data
</ButtonUI> </ButtonUI>
<ButtonUI color={BUTTON_COLOR_NEUTRAL} small onclick={() => setThreeD(!threeD())}>
{threeD() ? "Flat" : "3D"}
</ButtonUI>
<span class="text-xs text-ink-muted"> <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> </span>
</div> </div>
</Panel> </Panel>
@@ -1421,10 +1448,38 @@ function Charts() {
<Prose> <Prose>
One <code class="font-mono">kind</code> prop picks the form {" "} 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">"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. <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. The width is measured from the container, so a chart fills whatever column you give it.
</Prose> </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> </Section>
); );
} }

View 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";

View File

@@ -1,7 +1,7 @@
// The example is its own module so its go-chart dependency (and freetype / // 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 // 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). // stdlib-only. kjol is resolved locally via the replace below (no publish step).
module kjolweb module kjolwebsite
go 1.26.3 go 1.26.3

View File

@@ -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 // 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 // hot-swaps the wasm into the browser on change. It shows the coupling
// inversion — the framework (wasmdevserver) imports no app code; the app injects // 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): // 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 package main
import ( import (
@@ -20,9 +20,9 @@ import (
"kjol/wasmdevserver" "kjol/wasmdevserver"
"kjol/webui" "kjol/webui"
"kjolweb/app" "kjolwebsite/app"
"kjolweb/build" "kjolwebsite/build"
"kjolweb/internal/handlers" "kjolwebsite/internal/handlers"
) )
func main() { func main() {

View 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"]
}

View File

@@ -5,7 +5,7 @@
package main package main
import ( import (
"kjolweb/app" "kjolwebsite/app"
"kjol/vdom" "kjol/vdom"
"kjol/wasmruntime" "kjol/wasmruntime"
) )

View File

@@ -2,7 +2,7 @@
// scanning explicit content globs for utility candidates. Unlike the app bundler // 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 // (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 // 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): // Usage (globs are relative to -base; pass "**" for a recursive walk):
// //

View File

@@ -17,7 +17,7 @@ Only the styles in `jsbundler`'s `faStyleDirs` are read — currently `regular/`
<https://fontawesome.com/license> <https://fontawesome.com/license>
This is a **subset**, not the full kit: the ~43 icons the `uikit/` components and 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 is ~81,000 files across 17 style directories and has no business in a shared
submodule. submodule.

View File

@@ -164,6 +164,18 @@
--color-chart-6: #e34948; /* red */ --color-chart-6: #e34948; /* red */
--color-chart-7: #e87ba4; /* magenta */ --color-chart-7: #e87ba4; /* magenta */
--color-chart-8: #eb6834; /* orange */ --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-6: #e66767;
--color-chart-7: #d55181; --color-chart-7: #d55181;
--color-chart-8: #d95926; --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 /* The page's own background — painted before anything mounts, and behind it

View File

@@ -41,25 +41,32 @@ export interface ChartProps {
// bar / area: stack the series instead of grouping them side by side. // bar / area: stack the series instead of grouping them side by side.
stacked?: boolean; 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. // line / area: "smooth" draws a Catmull-Rom spline through the points.
curve?: "linear" | "smooth"; curve?: "linear" | "smooth";
// donut only: inner-radius fraction of the outer radius (0.6 by default). // donut only: inner-radius fraction of the outer radius (0.6 by default).
donutRatio?: number; 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. 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. width?: number; // fix the width instead of measuring the container.
class?: string; class?: string;
// Override the whole categorical palette (else the --color-chart-1..8 tokens). // Override the whole categorical palette (else the --color-chart-1..8 tokens).
palette?: string[]; 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; valueFormat?: (v: number) => string;
legend?: boolean; // default: true when there is more than one series (or a pie). legend?: boolean; // default: true when there is more than one series (or a pie).
grid?: boolean; // cartesian only; default true. grid?: boolean; // cartesian only; default true.
axes?: boolean; // cartesian only; default true. axes?: boolean; // cartesian only; default true.
tooltip?: boolean; // 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; 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 BAR_RADIUS = 4; // rounded data-end
const SEG_GAP = 2; // the surface gap between touching marks (stacked segments) const SEG_GAP = 2; // the surface gap between touching marks (stacked segments)
const MARK_R = 4; // hover marker radius (8px mark) const MARK_R = 4; // hover marker radius (8px mark)
const DEFAULT_DEPTH = 16; // 3D extrusion depth
// ── number + geometry helpers ─────────────────────────────────────────────────── // ── number + geometry helpers ───────────────────────────────────────────────────
@@ -122,17 +130,29 @@ function niceScale(min: number, max: number, maxTicks = 5): { min: number; max:
return { min: niceMin, max: niceMax, ticks }; return { min: niceMin, max: niceMax, ticks };
} }
// A column with the two corners at its VALUE end rounded and the baseline end square — // A rectangle with a chosen subset of corners rounded the data-end of a bar rounds,
// the mark spec. Handles growing up or down from the baseline. // the baseline end stays square, and which end that is depends on orientation and sign.
function columnPath(x: number, w: number, yBase: number, yVal: number, r: number): string { function roundRectPath(x: number, y: number, w: number, h: number, r: number, side: BarSide): string {
const h = Math.abs(yBase - yVal); const rr = Math.max(0, Math.min(r, w / 2, h / 2));
const rr = Math.max(0, Math.min(r, w / 2, h)); const tl = side === "top" || side === "left" ? rr : 0;
if (yVal <= yBase) { const tr = side === "top" || side === "right" ? rr : 0;
const t = yVal; const br = side === "bottom" || side === "right" ? rr : 0;
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`; 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}` +
const b = yVal; ` L${x + w},${y + h - br} Q${x + w},${y + h} ${x + w - br},${y + h}` +
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`; ` L${x + bl},${y + h} Q${x},${y + h} ${x},${y + h - bl}` +
` L${x},${y + tl} Q${x},${y} ${x + tl},${y} 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 { function linePathD(pts: [number, number][]): string {
@@ -157,22 +177,25 @@ function smoothPathD(pts: [number, number][]): string {
return d; 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 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. // One pie/donut slice from a0 to a1 degrees, tilted by k (k=1 upright). rIn === 0 gives a
function slicePathD(cx: number, cy: number, rOut: number, rIn: number, a0: number, a1: number): string { // 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 large = a1 - a0 > 180 ? 1 : 0;
const [ox0, oy0] = pointOnCircle(cx, cy, rOut, a0); const [ox0, oy0] = tiltPoint(cx, cy, rOut, a0, k);
const [ox1, oy1] = pointOnCircle(cx, cy, rOut, a1); const [ox1, oy1] = tiltPoint(cx, cy, rOut, a1, k);
if (rIn <= 0) { 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 [ix1, iy1] = tiltPoint(cx, cy, rIn, a1, k);
const [ix0, iy0] = pointOnCircle(cx, cy, rIn, a0); const [ix0, iy0] = tiltPoint(cx, cy, rIn, a0, k);
return `M${ox0},${oy0} A${rOut},${rOut} 0 ${large} 1 ${ox1},${oy1} L${ix1},${iy1} A${rIn},${rIn} 0 ${large} 0 ${ix0},${iy0} Z`; 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)); 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; 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) ───────────────────────────────────────────────── // ── cartesian (line / area / bar) ─────────────────────────────────────────────────
function CartesianChart(p: SubProps): JSXElement { function CartesianChart(p: SubProps): JSXElement {
const [hover, setHover] = createSignal<number | null>(null); 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 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); 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. // value; bars and areas always include zero so the baseline is honest.
const domain = createMemo(() => { const domain = createMemo(() => {
const series = p.props.series; const series = p.props.series;
@@ -258,15 +292,24 @@ function CartesianChart(p: SubProps): JSXElement {
return scale; 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 layout = createMemo(() => {
const d = domain(); const d = domain();
const showAxes = p.props.axes ?? true; const showAxes = p.props.axes ?? true;
const tickW = showAxes ? Math.max(...d.ticks.map((t) => p.fmt(t).length)) * 7 + 12 : 8; const valTickW = Math.max(...d.ticks.map((t) => p.fmt(t).length), 1) * 7 + 12;
const left = Math.max(28, tickW); const catLabelW = Math.max(...labels().map((s) => s.length), 1) * 7 + 12;
const top = 12; let left: number, bottom: number;
const bottom = showAxes ? 28 : 8; if (horiz()) {
const right = 12; 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 { return {
left, top, right, bottom, left, top, right, bottom,
plotW: Math.max(0, p.width - left - right), 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 d = domain(), l = layout();
const t = (v - d.min) / (d.max - d.min || 1); 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 bandFull = () => (horiz() ? layout().plotH : layout().plotW) / Math.max(1, n());
const bandCenter = (i: number) => layout().left + bandW() * (i + 0.5); const catStart = () => (horiz() ? layout().top : layout().left);
const baselineY = () => yToPx(clamp(0, domain().min, domain().max)); 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 // Bars resolved to plain rectangles + which side is the (rounded) data-end, so the
// band; each bar is capped at BAR_MAX_W with a SEG_GAP of air between neighbours. // renderer draws vertical and horizontal bars the same way.
const bars = createMemo(() => { const bars = createMemo(() => {
if (p.props.kind !== "bar") return [] as BarMark[]; if (p.props.kind !== "bar") return [] as BarMark[];
const out: 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; 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) { 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++) { for (let i = 0; i < count; i++) {
const x = layout().left + bw * i + (bw - colW) / 2; const off = catStart() + bf * i + (bf - thick) / 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.
let lastPos = -1, lastNeg = -1; let lastPos = -1, lastNeg = -1;
for (let s = 0; s < series.length; s++) { for (let s = 0; s < series.length; s++) {
const v = series[s].data[i] ?? 0; const v = series[s].data[i] ?? 0;
@@ -309,22 +357,25 @@ function CartesianChart(p: SubProps): JSXElement {
const to = from + v; const to = from + v;
if (v >= 0) accPos = to; else accNeg = to; if (v >= 0) accPos = to; else accNeg = to;
const isEnd = (v > 0 && s === lastPos) || (v < 0 && s === lastNeg); 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 inset = isEnd ? 0 : SEG_GAP; // 2px surface gap between segments
const yFrom = yToPx(from); // baseline-side edge const vFrom = valuePos(from);
const yVal = v >= 0 ? yToPx(to) + inset : yToPx(to) - inset; // pull the value end toward the baseline by the gap (except the outer end)
out.push({ x, w: colW, yBase: yFrom, yVal, seriesIdx: s, catIdx: i, value: v, round: isEnd }); 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 { } else {
const nS = Math.max(1, series.length); const nS = Math.max(1, series.length);
const groupW = Math.min(bw * 0.72, (BAR_MAX_W + SEG_GAP) * nS); const groupSize = Math.min(bf * 0.72, (BAR_MAX_W + SEG_GAP) * nS);
const each = Math.max(1, Math.min(BAR_MAX_W, groupW / nS - SEG_GAP)); const each = Math.max(1, Math.min(BAR_MAX_W, groupSize / nS - SEG_GAP));
for (let i = 0; i < count; i++) { 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++) { for (let s = 0; s < nS; s++) {
const v = series[s].data[i] ?? 0; const v = series[s].data[i] ?? 0;
const x = gx + s * (groupW / nS) + (groupW / nS - each) / 2; const off = g + s * (groupSize / nS) + (groupSize / nS - each) / 2;
out.push({ x, w: each, yBase: base, yVal: yToPx(v), seriesIdx: s, catIdx: i, value: v, round: true }); 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. // Line/area paths, one per series. Stacked areas ride on the running total below.
const paths = createMemo(() => { const paths = createMemo(() => {
if (p.props.kind !== "line" && p.props.kind !== "area") return [] as LineMark[]; 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 smooth = p.props.curve === "smooth";
const stackAcc = new Array(count).fill(0); const stackAcc = new Array(count).fill(0);
return p.props.series.map((s, si) => { return p.props.series.map((s, si) => {
@@ -344,8 +395,8 @@ function CartesianChart(p: SubProps): JSXElement {
const v = s.data[i] ?? 0; const v = s.data[i] ?? 0;
const yTop = p.props.stacked ? stackAcc[i] + v : v; const yTop = p.props.stacked ? stackAcc[i] + v : v;
const yBot = p.props.stacked ? stackAcc[i] : 0; const yBot = p.props.stacked ? stackAcc[i] : 0;
pts.push([bandCenter(i), yToPx(yTop)]); pts.push([catCenter(i), valuePos(yTop)]);
lowerPts.push([bandCenter(i), p.props.stacked ? yToPx(yBot) : base]); lowerPts.push([catCenter(i), p.props.stacked ? valuePos(yBot) : base]);
if (p.props.stacked) stackAcc[i] = yTop; if (p.props.stacked) stackAcc[i] = yTop;
} }
const line = smooth ? smoothPathD(pts) : linePathD(pts); 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 // 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. // JSX marks). Recomputed when the data, the size, or the hovered index changes.
const body = createMemo(() => { const body = createMemo(() => {
const l = layout(), d = domain(), hv = hover(); const l = layout(), d = domain(), hv = hover(), h = horiz();
const out: string[] = []; const out: string[] = [];
// gridlines + value ticks (perpendicular to the value axis)
for (const t of d.ticks) { for (const t of d.ticks) {
const y = yToPx(t); const vp = valuePos(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 (showGrid()) {
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>`); 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(); // baseline (the value-0 line), a touch stronger than the grid
out.push(`<line x1="${l.left}" x2="${l.left + l.plotW}" y1="${by}" y2="${by}" stroke="var(--color-line-strong)" stroke-width="1"/>`); 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()) { if (showAxes()) {
labels().forEach((lab, i) => 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) // crosshair (line/area only — a bar reader aims at a bar, not a hairline)
if (p.props.tooltip !== false && hv !== null && !isBar()) { if (p.props.tooltip !== false && hv !== null && !isBar()) {
const x = bandCenter(hv); const c = catCenter(hv);
out.push(`<line x1="${x}" x2="${x}" y1="${l.top}" y2="${l.top + l.plotH}" stroke="var(--color-line-strong)" stroke-width="1"/>`); 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; 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()) { 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"/>`); 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) => { const onMove = (e: PointerEvent) => {
if (p.props.tooltip === false) return; if (p.props.tooltip === false) return;
const rect = (e.currentTarget as SVGElement).getBoundingClientRect(); 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)); const px = e.clientX - rect.left, py = e.clientY - rect.top;
setHover(idx); const along = horiz() ? py - layout().top : px - layout().left;
setPointerY(e.clientY - rect.top); 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 ( return (
<> <>
<svg width={p.width} height={p.height} viewBox={`0 0 ${p.width} ${p.height}`} class="block overflow-visible" <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 <CartesianTooltip
props={p.props} colorOf={p.colorOf} fmt={p.fmt} props={p.props} colorOf={p.colorOf} fmt={p.fmt}
index={hover()!} label={labels()[hover()!] ?? ""} 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> </Show>
</> </>
); );
} }
interface BarMark { x: number; w: number; yBase: number; yVal: number; seriesIdx: number; catIdx: number; value: number; round: boolean; } function barSide(horiz: boolean, v: number): BarSide {
interface LineMark { seriesIdx: number; line: string; area: string; pts: [number, number][]; } return horiz ? (v >= 0 ? "right" : "left") : (v >= 0 ? "top" : "bottom");
}
function CartesianTooltip(p: { function CartesianTooltip(p: {
props: ChartProps; colorOf: (i: number) => string; fmt: (v: number) => string; 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 [hover, setHover] = createSignal<number | null>(null);
const [pointer, setPointer] = createSignal<[number, number]>([0, 0]); 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 values = () => p.props.series[0]?.data ?? [];
const labels = () => p.props.labels ?? values().map((_, i) => String(i + 1)); const labels = () => p.props.labels ?? values().map((_, i) => String(i + 1));
const total = () => values().reduce((a, v) => a + Math.max(0, v), 0); const total = () => values().reduce((a, v) => a + Math.max(0, v), 0);
const geo = () => { const geo = () => {
const cx = p.width / 2, cy = p.height / 2; const k = tilt();
const rOut = Math.max(0, Math.min(p.width, p.height) / 2 - 8); 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; 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 // 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 slices = createMemo(() => {
const t = total(); const t = total();
const out: { idx: number; a0: number; a1: number; value: number }[] = []; const out: { idx: number; a0: number; a1: number; value: number }[] = [];
@@ -491,20 +579,44 @@ function RadialChart(p: SubProps): JSXElement {
return out; 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 body = createMemo(() => {
const g = geo(), hv = hover(); const g = geo(), hv = hover();
const out: string[] = []; const out: string[] = [];
const positive = slices().filter((s) => s.value > 0); const positive = slices().filter((s) => s.value > 0);
if (positive.length === 1) { const single = positive.length === 1;
// one value: a full ring, since a 360° arc collapses to nothing
// 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]; const s = positive[0];
out.push(`<circle cx="${g.cx}" cy="${g.cy}" r="${g.rOut}" fill="${esc(p.colorOf(s.idx))}"/>`); 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(`<circle cx="${g.cx}" cy="${g.cy}" r="${g.rIn}" fill="var(--color-surface)"/>`); 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 { } else {
for (const s of slices()) { for (const s of slices()) {
if (s.a1 <= s.a0) continue; if (s.a1 <= s.a0) continue;
const op = hv === null || hv === s.idx ? 1 : 0.55; 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(""); return out.join("");
@@ -515,11 +627,9 @@ function RadialChart(p: SubProps): JSXElement {
const rect = (e.currentTarget as SVGElement).getBoundingClientRect(); const rect = (e.currentTarget as SVGElement).getBoundingClientRect();
const g = geo(); const g = geo();
const px = e.clientX - rect.left, py = e.clientY - rect.top; 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); const dist = Math.hypot(dx, dy);
if (dist > g.rOut || (g.rIn > 0 && dist < g.rIn)) { setHover(null); return; } 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; 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); const s = slices().find((s) => s.value > 0 && deg >= s.a0 && deg < s.a1);
setHover(s ? s.idx : null); setHover(s ? s.idx : null);

View File

@@ -7,7 +7,7 @@
// the kit are none the wiser. // the kit are none the wiser.
// //
// The storage key is deliberately the SAME one the Go/WASM kit uses // 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, // 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. // walk over to /js, and it is still dark. Two front-ends, one preference.

View 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, 16 (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 === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;"));
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>
);
}

File diff suppressed because one or more lines are too long

View File

@@ -20,7 +20,7 @@
// //
// The one thing tying it to a UI is the palette — the class names below are Tailwind's, so // 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. // 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 package lexer
import ( import (

View File

@@ -12,7 +12,7 @@ ui.Alert(ui.AlertGreen, "Done", vdom.Text("Saved."))
ui.ToggleSwitch(on, func(v bool){ on = v }, "Notifications", "", false, "") 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 ## Conventions