diff --git a/.vscode/launch.json b/.vscode/launch.json index 1b0b7470..4bc47f8f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,6 +7,11 @@ // Solid bundle). Codegen must run before the server is COMPILED — it writes // app/*.gen.go, which the server imports — and under the debugger the binary is built // by Delve, not by the server's own Build hook, so nothing else would generate it. + // + // GOWORK=off on every config: kjol-web is its own module (module kjolweb, replace kjol + // => ../..). Inside a parent project the parent's go.work — which lists kjol/go but not + // kjolweb — shadows it, and the build fails "main module (...) does not contain package + // .../kjol-web/server". Off, Go uses kjolweb's own go.mod; standalone it is a no-op. "version": "0.2.0", "configurations": [ { @@ -16,6 +21,7 @@ "mode": "auto", "program": "${workspaceFolder}/go/cmd/kjol-web/server", "cwd": "${workspaceFolder}/go/cmd/kjol-web", + "env": { "GOWORK": "off" }, "args": ["-addr", ":8085"], "preLaunchTask": "kjol-web: prebuild" }, @@ -26,6 +32,7 @@ "mode": "auto", "program": "${workspaceFolder}/go/cmd/kjol-web/server", "cwd": "${workspaceFolder}/go/cmd/kjol-web", + "env": { "GOWORK": "off" }, // -watch=false skips the server's own Build entirely, so wwwroot is served exactly // as it sits on disk. The prebuild is what puts current generated code, CSS and JS // bundles there; without it you would be debugging against stale artefacts — and @@ -43,6 +50,7 @@ // them and Go will not let you import a main. "program": "${workspaceFolder}/go/cmd/kjol-web/server", "cwd": "${workspaceFolder}/go/cmd/kjol-web", + "env": { "GOWORK": "off" }, "args": ["-build"] }, { @@ -52,6 +60,7 @@ "mode": "auto", "program": "${workspaceFolder}/go/cmd/wasmgen", "cwd": "${workspaceFolder}/go/cmd/kjol-web", + "env": { "GOWORK": "off" }, "args": ["./app"] }, { @@ -63,6 +72,7 @@ // All of it is Go, so all of it takes a breakpoint. "program": "${workspaceFolder}/go/cmd/bundle", "cwd": "${workspaceFolder}/go/cmd/kjol-web", + "env": { "GOWORK": "off" }, "args": [ "-app", "frontend", "-web", "../../jsruntime", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9e61020f..b44a703b 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -21,6 +21,12 @@ // so nothing else would generate it. Tailwind and the Solid bundle are in there too, so // that a -watch=false session serves current artefacts instead of stale ones. Each step // is also exposed on its own, so you can rerun just the one you need. + // + // The kjol-web tasks (cwd go/cmd/kjol-web) set GOWORK=off: kjol-web is its own module + // (module kjolweb, replace kjol => ../..), and inside a parent project the parent's + // go.work — which does not list kjolweb — would shadow it and the go command would fail + // "main module (...) does not contain package .../kjol-web/...". The kjol-module tasks + // (cwd go) keep the workspace, since kjol/go is what a parent's go.work does list. "version": "2.0.0", "tasks": [ { @@ -37,7 +43,7 @@ "type": "shell", "command": "go", "args": ["run", "kjol/cmd/wasmgen", "./app"], - "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" }, + "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web", "env": { "GOWORK": "off" } }, "problemMatcher": ["$go"], "presentation": { "reveal": "silent", "panel": "shared" }, "group": "build" @@ -77,7 +83,7 @@ "-out", "wwwroot", "-gen-ts", "frontend/src/ui/generated" ], - "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" }, + "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web", "env": { "GOWORK": "off" } }, "problemMatcher": ["$go"], "presentation": { "reveal": "silent", "panel": "shared" }, "group": "build" @@ -88,7 +94,7 @@ "type": "shell", "command": "go", "args": ["run", "./server"], - "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" }, + "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web", "env": { "GOWORK": "off" } }, "dependsOn": ["kjol-web: prebuild"], "isBackground": true, "problemMatcher": { @@ -112,7 +118,7 @@ "type": "shell", "command": "go", "args": ["run", "./server", "-build"], - "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" }, + "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web", "env": { "GOWORK": "off" } }, "problemMatcher": ["$go"], "group": "build" }, @@ -159,7 +165,7 @@ "detail": "The site's own tests (SSR, the icon registry, the AutoTable export bytes). Its own module, so `kjol: test ./...` does not reach it.", "type": "shell", "command": "go test ./...", - "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" }, + "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web", "env": { "GOWORK": "off" } }, "problemMatcher": ["$go"], "group": "test" } diff --git a/go/cmd/kjol-web/app/components.go b/go/cmd/kjol-web/app/components.go index 80d13313..6ade0f0b 100644 --- a/go/cmd/kjol-web/app/components.go +++ b/go/cmd/kjol-web/app/components.go @@ -1188,12 +1188,12 @@ func chartsSection() func() *VNode { ), ), - note("webui.ReactiveChart is a stub, and says so", - "The Solid kit's Chart wraps chart.js: it creates a Chart against a 2D context "+ - "on mount and pushes new data through an effect. None of that — canvas drawing, a JS "+ - "charting library, mount lifecycles — exists in the neutral Go runtime, so the Go port "+ - "keeps the props shape for parity and renders an empty canvas. Draw with go-chart "+ - "instead, as above. Pretending otherwise would be the one thing this site refuses to do."), + note("webui.Chart is a stub, and says so", + "The Solid kit's Chart draws its own SVG — nice-scale axes, rounded columns, arc slices, "+ + "a pointer-driven crosshair — with no charting library at all. That geometry has not "+ + "been ported to the neutral Go runtime yet, so the Go port keeps the props shape for "+ + "parity and renders only its box. This example draws with go-chart "+ + "instead, as above. Pretending the port drew charts would be the one thing this site refuses to do."), ) } } diff --git a/go/cmd/kjol-web/css/app.css b/go/cmd/kjol-web/css/app.css index c8551d83..0b1b8771 100644 --- a/go/cmd/kjol-web/css/app.css +++ b/go/cmd/kjol-web/css/app.css @@ -13,6 +13,46 @@ --------------------------------------------------------------------------- */ @custom-variant dark (&:where(.dark, .dark *)); +/* --------------------------------------------------------------------------- + Open Sans — self-hosted (files in wwwroot/fonts). One variable file per subset + carries weights 400–700 upright and italic, so the four faces below cover the + whole UI. unicode-range keeps the browser to the one subset a glyph needs, and + font-display: swap paints text in the fallback first rather than blocking on the + download. The same four blocks live in the /js side's frontend/css/style.css so + both front-ends render in one typeface. --font-sans (below) points at it. */ +@font-face { + font-family: "Open Sans"; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url("/fonts/opensans-latin-normal.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; +} +@font-face { + font-family: "Open Sans"; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url("/fonts/opensans-latin-ext-normal.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; +} +@font-face { + font-family: "Open Sans"; + font-style: italic; + font-weight: 400 700; + font-display: swap; + src: url("/fonts/opensans-latin-italic.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; +} +@font-face { + font-family: "Open Sans"; + font-style: italic; + font-weight: 400 700; + font-display: swap; + src: url("/fonts/opensans-latin-ext-italic.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; +} + /* App-side design tokens the webui kit references (Tailwind v4 @theme). Brand values live with the app; the kit stays generic. @@ -23,6 +63,12 @@ @theme { --radius-default: 0.375rem; + /* The UI typeface. --font-sans is what Tailwind's preflight points html at, and + the kit's utilities (font-sans) resolve to, so this one line moves the whole + site onto Open Sans; the fallbacks cover the swap window and any glyph outside + the vendored subsets. */ + --font-sans: "Open Sans", ui-sans-serif, system-ui, sans-serif; + /* Navy and red — the flag, muted. kjøl is a Norwegian word and the palette says so. Both are dark and low-key: the page is mostly prose, code and tables, and the brand's job is to mark the few things you can act on, not to compete with them for attention. @@ -123,6 +169,7 @@ html { background-color: var(--color-surface); color: var(--color-ink); + font-family: var(--font-sans); } /* --------------------------------------------------------------------------- diff --git a/go/cmd/kjol-web/frontend/css/style.css b/go/cmd/kjol-web/frontend/css/style.css index b223880b..821100fd 100644 --- a/go/cmd/kjol-web/frontend/css/style.css +++ b/go/cmd/kjol-web/frontend/css/style.css @@ -9,13 +9,54 @@ What is left is only what is genuinely this app's: the brand. The values below match the Go/WASM section's css/app.css on purpose — same - Lora, same sky accent — so that crossing between /wasm and /js reads as two - parts of ONE site rather than two demos that happen to share a domain. The + Open Sans, same navy accent — so that crossing between /wasm and /js reads as + two parts of ONE site rather than two demos that happen to share a domain. The two sections are built by completely different pipelines; they should not look like it. --------------------------------------------------------------------------- */ +/* Open Sans — self-hosted (files in wwwroot/fonts). One variable file per subset + carries weights 400–700 upright and italic. unicode-range keeps the browser to the + one subset a glyph needs; font-display: swap paints the fallback first. The same four + blocks live in the /wasm side's css/app.css so both front-ends share one typeface. */ +@font-face { + font-family: "Open Sans"; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url("/fonts/opensans-latin-normal.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; +} +@font-face { + font-family: "Open Sans"; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url("/fonts/opensans-latin-ext-normal.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; +} +@font-face { + font-family: "Open Sans"; + font-style: italic; + font-weight: 400 700; + font-display: swap; + src: url("/fonts/opensans-latin-italic.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; +} +@font-face { + font-family: "Open Sans"; + font-style: italic; + font-weight: 400 700; + font-display: swap; + src: url("/fonts/opensans-latin-ext-italic.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; +} + @theme { + /* The UI typeface — see the @font-face blocks above. This one line points the kit's + font-sans utilities and the html rule below onto Open Sans. */ + --font-sans: "Open Sans", ui-sans-serif, system-ui, sans-serif; + /* The brand: navy throughout, a muted flag-navy. These are the SAME values the Go/WASM section's css/app.css sets, and under the same names — so the Layers menu, the sidebar highlight and the callouts are the same navy on both sides of the site rather diff --git a/go/cmd/kjol-web/frontend/src/pages/Components.tsx b/go/cmd/kjol-web/frontend/src/pages/Components.tsx index 19262872..b546569d 100644 --- a/go/cmd/kjol-web/frontend/src/pages/Components.tsx +++ b/go/cmd/kjol-web/frontend/src/pages/Components.tsx @@ -83,7 +83,7 @@ import { CrmTabGroup, CrmSubTabGroup } from "@ui/CrmTabs"; import { Accordion, SingleAccordion } from "@ui/Accordion"; import { SidebarNav } from "@ui/Sidebar"; import { FuzzyMatch } from "@ui/FuzzyMatch"; -import ReactiveChart from "@ui/Chart"; +import { Chart, ChartSeries } from "@ui/Chart"; import { ThemeToggle, useTheme } from "@ui/Theme"; import { Demo } from "../layout/Demo.tsx"; @@ -1328,7 +1328,7 @@ const KIT_NAMES = [ "AutoTable", "Accordion", "Alert", "Badge", "ButtonUI", "Calendar", "CellGrid", "FormCombobox", "DatePicker", "FormInput", "Menu", "Modal", "FormMultiSelect", "Popover", "PrettyTable", "SegmentedButtons", "FormSignaturePad", "TabGroup", "ThemeToggle", "ToastProvider", "ToggleSwitch", - "Tooltip", "TutorialProvider", "SidebarNav", "ReactiveChart", "FuzzyMatch", + "Tooltip", "TutorialProvider", "SidebarNav", "Chart", "FuzzyMatch", ]; function Search() { @@ -1364,59 +1364,48 @@ function Search() { // ---- charts -------------------------------------------------------------- +const CHART_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + function Charts() { const [seed, setSeed] = createSignal(0); - const data = () => { - const base = [42, 17, 63, 28, 55, 9, 71]; - const values = base.map((v) => (seed() === 0 ? v : Math.max(4, (v * 7 + seed() * 13) % 80))); - return { - labels: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], - datasets: [ - { - label: "Requests", - data: values, - // A literal, not var(--color-primary): chart.js paints a canvas, and a - // canvas cannot read a CSS variable. So this is the one place in the - // site that names the navy instead of asking for it — and it names the - // DARK theme's lighter navy, which is the only one of the two that - // reads on both a white page and a near-black one. - backgroundColor: "#2b4f80", - borderColor: "#2b4f80", - }, - ], - }; - }; + // 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. + const shuffle = (base: number[]) => + base.map((v) => (seed() === 0 ? v : Math.max(4, (v * 7 + seed() * 13) % 80))); + const requests = (): ChartSeries => ({ name: "Requests", data: shuffle([42, 17, 63, 28, 55, 9, 71]) }); + const errors = (): ChartSeries => ({ name: "Errors", data: shuffle([8, 3, 12, 6, 9, 2, 14]) }); + const pieData = (): ChartSeries => ({ name: "Traffic", data: shuffle([40, 25, 20, 15, 8]) }); + const pieLabels = ["Direct", "Search", "Social", "Email", "Referral"]; return (
- @ui/Chart wraps chart.js: it creates a chart against a canvas - on mount and pushes new data through an effect, so changing the data updates the chart rather - than rebuilding the page. + @ui/Chart draws SVG — no charting library, nothing vendored, no{" "} + <canvas>. Because the picture is JSX it is already reactive: + change series and Solid re-renders the marks that moved. There is + no update() to call. - This is the one component in the kit that is genuinely different on the other side. The Go - port keeps the props shape for parity and renders an empty canvas — canvas drawing, a - JavaScript charting library and mount lifecycles do not exist in the neutral Go runtime. Kjol - Wasm Web draws SVG with go-chart instead, and draws it in the browser, in WebAssembly, exactly - as this one draws to a canvas here. Same place, different library. Neither side pretends - otherwise. + Being SVG buys two things a canvas cannot. The marks name the theme tokens + (var(--color-chart-1) …), so the palette inverts for dark mode with + the rest of the site — a canvas paints pixels and cannot read a CSS variable. And every mark is a + real element, so hovering shows a crosshair and one tooltip listing every series at that point. - - {/* The wrapper is h-full with maintainAspectRatio:false, so the PARENT must - have an explicit height or the canvas collapses to nothing at all. */} -
-
- + +
+
+
-
- +
+ +
+
+ +
+
+
@@ -1424,17 +1413,18 @@ function Charts() { New data - The chart updates in place — it is not torn down and rebuilt. + Every chart updates in place — hover any of them for the tooltip.
- - Chart.tsx imports chart.js at the top level and registers - every controller on module load. An app that uses it must vendor chart.js and{" "} - @kurkle/color, or the bundle does not degrade — it fails to - evaluate, and you get an empty page and one line in the console. - + + One kind prop picks the form —{" "} + "line" | "area" | "bar" | "pie" | "donut" — and{" "} + stacked, curve,{" "} + palette and valueFormat refine it. + The width is measured from the container, so a chart fills whatever column you give it. +
); } diff --git a/go/cmd/kjol-web/frontend/vendor/vendor.json b/go/cmd/kjol-web/frontend/vendor/vendor.json index e9188b80..e70f842e 100644 --- a/go/cmd/kjol-web/frontend/vendor/vendor.json +++ b/go/cmd/kjol-web/frontend/vendor/vendor.json @@ -5,12 +5,8 @@ "//3": "Only the files the bundler actually pins are vendored, not the whole npm packages — 3.5 MB rather than 62 MB of type definitions, CJS builds and documentation. If a subpath import is ever added that reaches outside dist/ or build/, this is the first place it will fail.", - "//4": "chart.js (and its @kurkle/color dependency) are here for the same reason: @ui/Chart imports chart.js at the top level and registers every controller on module load. Its wrapper is h-full with maintainAspectRatio:false, so the PARENT must have an explicit height or the canvas collapses to nothing.", - "entrypoints": { "pdf-lib": "pdf-lib/dist/pdf-lib.esm.js", - "pdfjs-dist": "pdfjs-dist/build/pdf.mjs", - "chart.js": "chart.js/dist/chart.js", - "@kurkle/color": "@kurkle/color/dist/color.esm.js" + "pdfjs-dist": "pdfjs-dist/build/pdf.mjs" } } diff --git a/go/cmd/kjol-web/opensans.css b/go/cmd/kjol-web/opensans.css new file mode 100644 index 00000000..445da7f2 --- /dev/null +++ b/go/cmd/kjol-web/opensans.css @@ -0,0 +1,200 @@ +/* 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; +} diff --git a/go/cmd/kjol-web/wwwroot/fonts/lora-latin-ext-italic.woff2 b/go/cmd/kjol-web/wwwroot/fonts/lora-latin-ext-italic.woff2 deleted file mode 100644 index 61ff30a0..00000000 Binary files a/go/cmd/kjol-web/wwwroot/fonts/lora-latin-ext-italic.woff2 and /dev/null differ diff --git a/go/cmd/kjol-web/wwwroot/fonts/lora-latin-ext-normal.woff2 b/go/cmd/kjol-web/wwwroot/fonts/lora-latin-ext-normal.woff2 deleted file mode 100644 index b8d5a4fb..00000000 Binary files a/go/cmd/kjol-web/wwwroot/fonts/lora-latin-ext-normal.woff2 and /dev/null differ diff --git a/go/cmd/kjol-web/wwwroot/fonts/lora-latin-italic.woff2 b/go/cmd/kjol-web/wwwroot/fonts/lora-latin-italic.woff2 deleted file mode 100644 index 856707df..00000000 Binary files a/go/cmd/kjol-web/wwwroot/fonts/lora-latin-italic.woff2 and /dev/null differ diff --git a/go/cmd/kjol-web/wwwroot/fonts/lora-latin-normal.woff2 b/go/cmd/kjol-web/wwwroot/fonts/lora-latin-normal.woff2 deleted file mode 100644 index 918a4772..00000000 Binary files a/go/cmd/kjol-web/wwwroot/fonts/lora-latin-normal.woff2 and /dev/null differ diff --git a/go/cmd/kjol-web/wwwroot/fonts/opensans-latin-ext-italic.woff2 b/go/cmd/kjol-web/wwwroot/fonts/opensans-latin-ext-italic.woff2 new file mode 100644 index 00000000..bc2aea0d Binary files /dev/null and b/go/cmd/kjol-web/wwwroot/fonts/opensans-latin-ext-italic.woff2 differ diff --git a/go/cmd/kjol-web/wwwroot/fonts/opensans-latin-ext-normal.woff2 b/go/cmd/kjol-web/wwwroot/fonts/opensans-latin-ext-normal.woff2 new file mode 100644 index 00000000..08bed85e Binary files /dev/null and b/go/cmd/kjol-web/wwwroot/fonts/opensans-latin-ext-normal.woff2 differ diff --git a/go/cmd/kjol-web/wwwroot/fonts/opensans-latin-italic.woff2 b/go/cmd/kjol-web/wwwroot/fonts/opensans-latin-italic.woff2 new file mode 100644 index 00000000..5de3fead Binary files /dev/null and b/go/cmd/kjol-web/wwwroot/fonts/opensans-latin-italic.woff2 differ diff --git a/go/cmd/kjol-web/wwwroot/fonts/opensans-latin-normal.woff2 b/go/cmd/kjol-web/wwwroot/fonts/opensans-latin-normal.woff2 new file mode 100644 index 00000000..b5d54e76 Binary files /dev/null and b/go/cmd/kjol-web/wwwroot/fonts/opensans-latin-normal.woff2 differ diff --git a/go/jsbundler/js.go b/go/jsbundler/js.go index 8773419e..271820ea 100644 --- a/go/jsbundler/js.go +++ b/go/jsbundler/js.go @@ -26,10 +26,12 @@ func esbuildDefine() map[string]string { } // resolveEntryPoint returns the path (relative to frontendDir) of the -// SPA entry point, preferring app.tsx over app.js. +// SPA entry point, preferring app.tsx, then app.ts, then app.js. func resolveEntryPoint() string { - if _, err := os.Stat(filepath.Join(frontendDir, "src/app.tsx")); err == nil { - return "src/app.tsx" + for _, e := range []string{"src/app.tsx", "src/app.ts"} { + if _, err := os.Stat(filepath.Join(frontendDir, e)); err == nil { + return e + } } return "src/app.js" } @@ -79,8 +81,16 @@ func bundleJSEntry(entry, outName string) (bundleStats, error) { plugins := []esbuild.Plugin{aliasPlugin(), defaultExportShimPlugin(), Plugin()} plugins = append(plugins, assetURLPlugin(), vendorManifestPlugin(entrypoints, vdirs)) + // esbuild wants forward slashes and a leading "./"; a relative path with the + // OS separator (filepath.Join gives backslashes on Windows) or without "./" is + // read as a bare package specifier and fails to resolve. + entryPath := filepath.ToSlash(entry) + if !filepath.IsAbs(entry) && !strings.HasPrefix(entryPath, "./") && !strings.HasPrefix(entryPath, "../") { + entryPath = "./" + entryPath + } + result := esbuild.Build(esbuild.BuildOptions{ - EntryPoints: []string{entry}, + EntryPoints: []string{entryPath}, Outfile: filepath.Join(outputDir, outName), Bundle: true, Write: true, diff --git a/go/jsruntime/styles/theme.css b/go/jsruntime/styles/theme.css index 08b44987..d5267653 100644 --- a/go/jsruntime/styles/theme.css +++ b/go/jsruntime/styles/theme.css @@ -141,6 +141,29 @@ --color-on-warning: var(--color-white); --color-danger: var(--color-red-500); --color-on-danger: var(--color-white); + + /* ---- the categorical CHART palette ------------------------------------- + Eight fixed slots for uikit/Chart.tsx. A series is painted by its INDEX + (slot 1, 2, 3 …) and never by a cycled/derived hue — a ninth series folds + into "Other" rather than inventing a colour. Unlike the state colours these + are a set, tuned as a set: the ordering maximises the minimum colour-blind + ΔE between neighbouring slots, so adjacent series stay distinguishable under + protanopia/deuteranopia (worst adjacent ΔE 24 in light, 10 in dark — see the + data-viz palette validator). + + These CAN be named in a component because a chart is SVG and SVG fills read + CSS variables (a canvas cannot, so it would need a literal hex). The dark + column below is the same eight hues stepped for the near-black surface, not a + second palette. Brand overrides by redefining these (or passing `palette` to a + single chart). */ + --color-chart-1: #2a78d6; /* blue */ + --color-chart-2: #1baf7a; /* aqua */ + --color-chart-3: #eda100; /* yellow */ + --color-chart-4: #008300; /* green */ + --color-chart-5: #4a3aa7; /* violet */ + --color-chart-6: #e34948; /* red */ + --color-chart-7: #e87ba4; /* magenta */ + --color-chart-8: #eb6834; /* orange */ } /* --------------------------------------------------------------------------- @@ -182,6 +205,19 @@ --color-primary-subtle: #1f1f24; --color-primary-border: #3d3d45; --color-accent: #e5e5e5; + + /* The chart palette, re-stepped for the dark surface. Same eight hues, lifted + into the dark-mode lightness band so every slot clears 3:1 against #101013 — + a light-mode aqua or yellow would sink into the surface. Validated as a set + against the dark surface, exactly as the light column was against white. */ + --color-chart-1: #3987e5; + --color-chart-2: #199e70; + --color-chart-3: #c98500; + --color-chart-4: #008300; + --color-chart-5: #9085e9; + --color-chart-6: #e66767; + --color-chart-7: #d55181; + --color-chart-8: #d95926; } /* The page's own background — painted before anything mounts, and behind it diff --git a/go/jsruntime/uikit/Chart.tsx b/go/jsruntime/uikit/Chart.tsx index be7c61f9..3d586172 100644 --- a/go/jsruntime/uikit/Chart.tsx +++ b/go/jsruntime/uikit/Chart.tsx @@ -1,91 +1,582 @@ -import { createEffect, onCleanup, onMount } from "solid-js"; -import { Chart, registerables } from "chart.js"; +import { createMemo, createSignal, For, Show, onMount, onCleanup, JSXElement } from "solid-js"; -// chart.js v4 is tree-shakeable and ships nothing registered by default; register -// all controllers/elements/scales once so any chart type works (the old UMD shim -// did this implicitly). -Chart.register(...registerables); +// A dependency-free, reactive chart. It draws SVG — nothing is vendored, nothing is +// registered on a global, and there is no . +// +// Being SVG is what makes it small. The picture IS the reactive tree: change +// props.series and Solid re-renders it, so there is no imperative update() to call and +// no mount-time construction pushing data into a canvas the reactive system cannot see. +// Marks name CSS variables (`fill: var(--color-chart-1)`), so the palette themes and +// inverts for dark mode for free — a canvas paints pixels and cannot read a variable. +// +// The marks are assembled as an SVG STRING and set with innerHTML, not written as JSX +// / elements. The Go-native Solid compiler namespaces an element as SVG only +// when it is written literally inside an in the same template; a produced by +// control flow (, a callback) is created in the HTML namespace and never paints. +// Setting innerHTML on an parses the string in the SVG namespace — the same trick +// uikit/Icons.tsx uses. Pointer handlers ride the element (a real Solid node), and +// which bar/slice the pointer is over is computed from geometry, not per-element listeners. +// +// Layout is measured, not scaled: a ResizeObserver reports the container's pixel width +// and the SVG is drawn at that width (viewBox === pixel box, 1:1), so text stays crisp +// at any size and pointer coordinates map straight onto the drawing. On the server (no +// ResizeObserver) it renders once at a sane default width and re-measures on mount. -type ChartType = "line" | "bar" | "radar" | "doughnut" | "polarArea" | "bubble" | "pie" | "scatter"; +export type ChartKind = "line" | "area" | "bar" | "pie" | "donut"; -interface ReactiveChartProps { - type: ChartType; - data: unknown; - options?: object; +export interface ChartSeries { + name: string; + data: number[]; + // Override this series' palette slot. Any CSS colour; a var(--color-chart-n) keeps + // it theme-aware. Leave unset to take the Nth slot of the categorical palette. + color?: string; +} + +export interface ChartProps { + kind: ChartKind; + // The x categories (line/area/bar) or the slice names (pie/donut). Optional only + // because a bare numeric series can fall back to 1..n; supply them for real charts. + labels?: string[]; + series: ChartSeries[]; + + // bar / area: stack the series instead of grouping them side by side. + stacked?: boolean; + // line / area: "smooth" draws a Catmull-Rom spline through the points. + curve?: "linear" | "smooth"; + // donut only: inner-radius fraction of the outer radius (0.6 by default). + donutRatio?: number; + + height?: number; // px of the plot area (default 300). The legend adds its own height. + width?: number; // fix the width instead of measuring the container. class?: string; + + // Override the whole categorical palette (else the --color-chart-1..8 tokens). + palette?: string[]; + // Format a value for the y-axis ticks and the tooltip. Defaults to en-US grouping. + valueFormat?: (v: number) => string; + + legend?: boolean; // default: true when there is more than one series (or a pie). + grid?: boolean; // cartesian only; default true. + axes?: boolean; // cartesian only; default true. + tooltip?: boolean; // default true. + yMin?: number; // pin the y domain instead of deriving it from the data. + yMax?: number; } -interface ChartInstance { - destroy(): void; - update(): void; - data: unknown; - options: object; +// The palette a component IS allowed to name — the tokens carry the theming. Referenced +// by index; a ninth series is the caller's problem (fold it into "Other"), never a +// synthesised ninth hue. +const CHART_TOKENS = [ + "var(--color-chart-1)", "var(--color-chart-2)", "var(--color-chart-3)", "var(--color-chart-4)", + "var(--color-chart-5)", "var(--color-chart-6)", "var(--color-chart-7)", "var(--color-chart-8)", +]; + +const DEFAULT_HEIGHT = 300; +const DEFAULT_WIDTH = 640; // used only until the container is measured (and on the server) +const BAR_MAX_W = 24; // cap a bar's thickness; the band's leftover is deliberate air +const BAR_RADIUS = 4; // rounded data-end +const SEG_GAP = 2; // the surface gap between touching marks (stacked segments) +const MARK_R = 4; // hover marker radius (8px mark) + +// ── number + geometry helpers ─────────────────────────────────────────────────── + +// A value or label can carry markup-breaking characters (a series name from a CSV +// header, a "<"), and it goes into an innerHTML string — escape everything untrusted. +const esc = (s: unknown) => + String(s).replace(/[&<>"]/g, (c) => (c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """)); + +// Constructed lazily, not at module load: the SSR runtime (goja) may import this file +// while baking a page, and building the formatter at import time would run there too. +let _intl: Intl.NumberFormat | null = null; +const defaultFormat = (v: number) => { + if (!Number.isFinite(v)) return String(v); + _intl ??= new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }); + return _intl.format(v); +}; + +// "Nice" axis bounds: rounded min/max plus tick values a reader recognises (0, 20, 40 …) +// rather than the raw data extent. +function niceScale(min: number, max: number, maxTicks = 5): { min: number; max: number; ticks: number[] } { + if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) { + // A flat or empty series still needs a drawable axis. + const v = Number.isFinite(max) ? max : 0; + min = Math.min(0, v); + max = v === min ? min + 1 : Math.max(0, v); + } + const niceNum = (range: number, round: boolean) => { + const exp = Math.floor(Math.log10(range)); + const frac = range / Math.pow(10, exp); + const nf = round + ? frac < 1.5 ? 1 : frac < 3 ? 2 : frac < 7 ? 5 : 10 + : frac <= 1 ? 1 : frac <= 2 ? 2 : frac <= 5 ? 5 : 10; + return nf * Math.pow(10, exp); + }; + const step = niceNum((max - min) / Math.max(1, maxTicks - 1), true); + const niceMin = Math.floor(min / step) * step; + const niceMax = Math.ceil(max / step) * step; + const ticks: number[] = []; + const decimals = Math.max(0, -Math.floor(Math.log10(step))); + for (let v = niceMin; v <= niceMax + step * 0.5; v += step) { + ticks.push(Number(v.toFixed(decimals + 2))); + } + return { min: niceMin, max: niceMax, ticks }; } -type ChartCtor = new (ctx: CanvasRenderingContext2D, cfg: object) => ChartInstance; +// A column with the two corners at its VALUE end rounded and the baseline end square — +// the mark spec. Handles growing up or down from the baseline. +function columnPath(x: number, w: number, yBase: number, yVal: number, r: number): string { + const h = Math.abs(yBase - yVal); + const rr = Math.max(0, Math.min(r, w / 2, h)); + if (yVal <= yBase) { + const t = yVal; + return `M${x},${yBase} L${x},${t + rr} Q${x},${t} ${x + rr},${t} L${x + w - rr},${t} Q${x + w},${t} ${x + w},${t + rr} L${x + w},${yBase} Z`; + } + const b = yVal; + return `M${x},${yBase} L${x},${b - rr} Q${x},${b} ${x + rr},${b} L${x + w - rr},${b} Q${x + w},${b} ${x + w},${b - rr} L${x + w},${yBase} Z`; +} -export default function ReactiveChart(props: ReactiveChartProps) { - let canvasRef: HTMLCanvasElement | undefined; - let chartInstance: ChartInstance | null = null; +function linePathD(pts: [number, number][]): string { + if (!pts.length) return ""; + return pts.map((p, i) => `${i ? "L" : "M"}${p[0]},${p[1]}`).join(" "); +} + +// Catmull-Rom → cubic bezier, so the curve passes through every point (a plain bezier +// smoothing would miss them). +function smoothPathD(pts: [number, number][]): string { + if (pts.length < 3) return linePathD(pts); + let d = `M${pts[0][0]},${pts[0][1]}`; + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[i - 1] ?? pts[i]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[i + 2] ?? p2; + const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6; + const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6; + d += ` C${c1x},${c1y} ${c2x},${c2y} ${p2[0]},${p2[1]}`; + } + return d; +} + +function pointOnCircle(cx: number, cy: number, r: number, deg: number): [number, number] { + const a = (deg - 90) * Math.PI / 180; // 0° at 12 o'clock, clockwise + return [cx + r * Math.cos(a), cy + r * Math.sin(a)]; +} + +// One pie/donut slice from a0 to a1 degrees. rIn === 0 gives a pie wedge. +function slicePathD(cx: number, cy: number, rOut: number, rIn: number, a0: number, a1: number): string { + const large = a1 - a0 > 180 ? 1 : 0; + const [ox0, oy0] = pointOnCircle(cx, cy, rOut, a0); + const [ox1, oy1] = pointOnCircle(cx, cy, rOut, a1); + if (rIn <= 0) { + return `M${cx},${cy} L${ox0},${oy0} A${rOut},${rOut} 0 ${large} 1 ${ox1},${oy1} Z`; + } + const [ix1, iy1] = pointOnCircle(cx, cy, rIn, a1); + const [ix0, iy0] = pointOnCircle(cx, cy, rIn, a0); + return `M${ox0},${oy0} A${rOut},${rOut} 0 ${large} 1 ${ox1},${oy1} L${ix1},${iy1} A${rIn},${rIn} 0 ${large} 0 ${ix0},${iy0} Z`; +} + +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); + +// ── the component ──────────────────────────────────────────────────────────────── + +export function Chart(props: ChartProps): JSXElement { + let wrap: HTMLDivElement | undefined; + const [measured, setMeasured] = createSignal(props.width ?? DEFAULT_WIDTH); + const width = () => props.width ?? measured(); + const height = () => props.height ?? DEFAULT_HEIGHT; onMount(() => { - // Defer initialization until the canvas is connected to the document. - // @solidjs/router creates route components before inserting them into - // the DOM, and Chart.js needs getComputedStyle which requires a - // connected element with ownerDocument.defaultView. - const init = () => { - if (!canvasRef) return; - if (!canvasRef.isConnected) { - requestAnimationFrame(init); - return; - } - - const ctx = canvasRef.getContext("2d"); - if (!ctx) return; - - chartInstance = new (Chart as unknown as ChartCtor)(ctx, { - type: props.type, - data: props.data, - options: { - responsive: true, - maintainAspectRatio: false, - ...(props.options ?? {}), - }, - }); - }; - init(); + if (props.width != null || !wrap) return; + const read = () => { if (wrap && wrap.clientWidth > 0) setMeasured(wrap.clientWidth); }; + read(); + if (typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(read); + ro.observe(wrap); + onCleanup(() => ro.disconnect()); }); - onCleanup(() => { - if (chartInstance) { - chartInstance.destroy(); - chartInstance = null; - } - }); - - createEffect(() => { - const data = props.data; - if (chartInstance) { - chartInstance.data = data; - chartInstance.update(); - } - }); - - createEffect(() => { - const options = props.options; - if (chartInstance) { - chartInstance.options = { - responsive: true, - maintainAspectRatio: false, - ...(options ?? {}), - }; - chartInstance.update(); - } - }); + const isRadial = () => props.kind === "pie" || props.kind === "donut"; + const colorOf = (i: number) => + props.series[i]?.color ?? props.palette?.[i % (props.palette.length || 1)] ?? CHART_TOKENS[i % CHART_TOKENS.length]; + const fmt = (v: number) => (props.valueFormat ?? defaultFormat)(v); + const showLegend = () => props.legend ?? (isRadial() || props.series.length > 1); return ( -
- canvasRef = el}> +
+ + }> + + + + + +
+ ); +} + +interface SubProps { + props: ChartProps; + width: number; + height: number; + colorOf: (i: number) => string; + fmt: (v: number) => string; +} + +// ── cartesian (line / area / bar) ───────────────────────────────────────────────── + +function CartesianChart(p: SubProps): JSXElement { + const [hover, setHover] = createSignal(null); + const [pointerY, setPointerY] = createSignal(0); + + const labels = () => p.props.labels ?? p.props.series[0]?.data.map((_, i) => String(i + 1)) ?? []; + const n = () => Math.max(labels().length, ...p.props.series.map((s) => s.data.length), 0); + + // The y domain. Stacked bars/areas reach the tallest STACK, not the tallest single + // value; bars and areas always include zero so the baseline is honest. + const domain = createMemo(() => { + const series = p.props.series; + const count = n(); + const includeZero = p.props.kind === "bar" || p.props.kind === "area"; + let lo = Infinity, hi = -Infinity; + if (p.props.stacked) { + for (let i = 0; i < count; i++) { + let pos = 0, neg = 0; + for (const s of series) { + const v = s.data[i] ?? 0; + if (v >= 0) pos += v; else neg += v; + } + hi = Math.max(hi, pos); lo = Math.min(lo, neg); + } + } else { + for (const s of series) for (const v of s.data) { hi = Math.max(hi, v); lo = Math.min(lo, v); } + } + if (includeZero) { lo = Math.min(lo, 0); hi = Math.max(hi, 0); } + const scale = niceScale(p.props.yMin ?? lo, p.props.yMax ?? hi); + if (p.props.yMin != null) scale.min = p.props.yMin; + if (p.props.yMax != null) scale.max = p.props.yMax; + return scale; + }); + + // Left margin follows the widest y tick, so labels never clip and never float. + const layout = createMemo(() => { + const d = domain(); + const showAxes = p.props.axes ?? true; + const tickW = showAxes ? Math.max(...d.ticks.map((t) => p.fmt(t).length)) * 7 + 12 : 8; + const left = Math.max(28, tickW); + const top = 12; + const bottom = showAxes ? 28 : 8; + const right = 12; + return { + left, top, right, bottom, + plotW: Math.max(0, p.width - left - right), + plotH: Math.max(0, p.height - top - bottom), + }; + }); + + const yToPx = (v: number) => { + const d = domain(), l = layout(); + const t = (v - d.min) / (d.max - d.min || 1); + return l.top + l.plotH * (1 - t); + }; + const bandW = () => layout().plotW / Math.max(1, n()); + const bandCenter = (i: number) => layout().left + bandW() * (i + 0.5); + const baselineY = () => yToPx(clamp(0, domain().min, domain().max)); + + // Grouped bar geometry: the series share a centred group that occupies ~72% of the + // band; each bar is capped at BAR_MAX_W with a SEG_GAP of air between neighbours. + const bars = createMemo(() => { + if (p.props.kind !== "bar") return [] as BarMark[]; + const out: BarMark[] = []; + const count = n(), bw = bandW(), base = baselineY(); + const series = p.props.series; + if (p.props.stacked) { + const colW = Math.min(BAR_MAX_W, bw * 0.72); + for (let i = 0; i < count; i++) { + const x = layout().left + bw * i + (bw - colW) / 2; + // The rounded data-end belongs to the OUTERMOST segment of each arm; the + // interior boundaries are separated by the surface gap, not by rounding. + let lastPos = -1, lastNeg = -1; + for (let s = 0; s < series.length; s++) { + const v = series[s].data[i] ?? 0; + if (v > 0) lastPos = s; else if (v < 0) lastNeg = s; + } + let accPos = 0, accNeg = 0; + for (let s = 0; s < series.length; s++) { + const v = series[s].data[i] ?? 0; + if (v === 0) continue; + const from = v >= 0 ? accPos : accNeg; + const to = from + v; + if (v >= 0) accPos = to; else accNeg = to; + const isEnd = (v > 0 && s === lastPos) || (v < 0 && s === lastNeg); + const inset = isEnd ? 0 : SEG_GAP; // shrink toward the baseline for the 2px gap + const yFrom = yToPx(from); // baseline-side edge + const yVal = v >= 0 ? yToPx(to) + inset : yToPx(to) - inset; + out.push({ x, w: colW, yBase: yFrom, yVal, seriesIdx: s, catIdx: i, value: v, round: isEnd }); + } + } + } else { + const nS = Math.max(1, series.length); + const groupW = Math.min(bw * 0.72, (BAR_MAX_W + SEG_GAP) * nS); + const each = Math.max(1, Math.min(BAR_MAX_W, groupW / nS - SEG_GAP)); + for (let i = 0; i < count; i++) { + const gx = layout().left + bw * i + (bw - groupW) / 2; + for (let s = 0; s < nS; s++) { + const v = series[s].data[i] ?? 0; + const x = gx + s * (groupW / nS) + (groupW / nS - each) / 2; + out.push({ x, w: each, yBase: base, yVal: yToPx(v), seriesIdx: s, catIdx: i, value: v, round: true }); + } + } + } + return out; + }); + + // Line/area paths, one per series. Stacked areas ride on the running total below. + const paths = createMemo(() => { + if (p.props.kind !== "line" && p.props.kind !== "area") return [] as LineMark[]; + const count = n(), base = baselineY(); + const smooth = p.props.curve === "smooth"; + const stackAcc = new Array(count).fill(0); + return p.props.series.map((s, si) => { + const pts: [number, number][] = []; + const lowerPts: [number, number][] = []; + for (let i = 0; i < count; i++) { + const v = s.data[i] ?? 0; + const yTop = p.props.stacked ? stackAcc[i] + v : v; + const yBot = p.props.stacked ? stackAcc[i] : 0; + pts.push([bandCenter(i), yToPx(yTop)]); + lowerPts.push([bandCenter(i), p.props.stacked ? yToPx(yBot) : base]); + if (p.props.stacked) stackAcc[i] = yTop; + } + const line = smooth ? smoothPathD(pts) : linePathD(pts); + const rev = [...lowerPts].reverse(); + const area = line + " L" + linePathD(rev).slice(1) + " Z"; + return { seriesIdx: si, line, area, pts }; + }); + }); + + const showAxes = () => p.props.axes ?? true; + const showGrid = () => p.props.grid ?? true; + const isBar = () => p.props.kind === "bar"; + + // The whole SVG interior, as a string (see the file header for why innerHTML and not + // JSX marks). Recomputed when the data, the size, or the hovered index changes. + const body = createMemo(() => { + const l = layout(), d = domain(), hv = hover(); + const out: string[] = []; + + for (const t of d.ticks) { + const y = yToPx(t); + if (showGrid()) out.push(``); + if (showAxes()) out.push(`${esc(p.fmt(t))}`); + } + + const by = baselineY(); + out.push(``); + + if (showAxes()) { + labels().forEach((lab, i) => + out.push(`${esc(lab)}`)); + } + + // crosshair (line/area only — a bar reader aims at a bar, not a hairline) + if (p.props.tooltip !== false && hv !== null && !isBar()) { + const x = bandCenter(hv); + out.push(``); + } + + for (const b of bars()) { + const op = hv === null || hv === b.catIdx ? 1 : 0.5; + out.push(``); + } + + for (const pth of paths()) { + if (p.props.kind === "area") out.push(``); + } + for (const pth of paths()) { + out.push(``); + } + + // hover markers on line/area, at the snapped index + if (p.props.tooltip !== false && hv !== null && !isBar()) { + for (const pth of paths()) { + const pt = pth.pts[hv]; + if (pt) out.push(``); + } + } + + return out.join(""); + }); + + const onMove = (e: PointerEvent) => { + if (p.props.tooltip === false) return; + const rect = (e.currentTarget as SVGElement).getBoundingClientRect(); + const idx = clamp(Math.floor((e.clientX - rect.left - layout().left) / bandW()), 0, Math.max(0, n() - 1)); + setHover(idx); + setPointerY(e.clientY - rect.top); + }; + + return ( + <> + setHover(null)} /> + + + + + ); +} + +interface BarMark { x: number; w: number; yBase: number; yVal: number; seriesIdx: number; catIdx: number; value: number; round: boolean; } +interface LineMark { seriesIdx: number; line: string; area: string; pts: [number, number][]; } + +function CartesianTooltip(p: { + props: ChartProps; colorOf: (i: number) => string; fmt: (v: number) => string; + index: number; label: string; anchorX: number; anchorY: number; width: number; height: number; +}): JSXElement { + const flipLeft = () => p.anchorX > p.width / 2; + const style = () => ({ + left: `${p.anchorX}px`, + top: `${clamp(p.anchorY, 8, p.height - 8)}px`, + transform: `translate(${flipLeft() ? "calc(-100% - 12px)" : "12px"}, -50%)`, + }); + return ( +
+
{p.label}
+ {(s, i) => ( +
+ + {s.name} + + {p.fmt(s.data[p.index] ?? 0)} + +
+ )}
+
+ ); +} + +// ── radial (pie / donut) ────────────────────────────────────────────────────────── + +function RadialChart(p: SubProps): JSXElement { + const [hover, setHover] = createSignal(null); + const [pointer, setPointer] = createSignal<[number, number]>([0, 0]); + + const values = () => p.props.series[0]?.data ?? []; + const labels = () => p.props.labels ?? values().map((_, i) => String(i + 1)); + const total = () => values().reduce((a, v) => a + Math.max(0, v), 0); + + const geo = () => { + const cx = p.width / 2, cy = p.height / 2; + const rOut = Math.max(0, Math.min(p.width, p.height) / 2 - 8); + const rIn = p.props.kind === "donut" ? rOut * (p.props.donutRatio ?? 0.6) : 0; + return { cx, cy, rOut, rIn }; + }; + + // Slices with their angular spans. A lone value becomes a full ring (drawn as a + // circle, since an arc from 0° to 360° collapses). + const slices = createMemo(() => { + const t = total(); + const out: { idx: number; a0: number; a1: number; value: number }[] = []; + let a = 0; + values().forEach((v, i) => { + const sweep = t > 0 ? (Math.max(0, v) / t) * 360 : 0; + out.push({ idx: i, a0: a, a1: a + sweep, value: Math.max(0, v) }); + a += sweep; + }); + return out; + }); + + const body = createMemo(() => { + const g = geo(), hv = hover(); + const out: string[] = []; + const positive = slices().filter((s) => s.value > 0); + if (positive.length === 1) { + // one value: a full ring, since a 360° arc collapses to nothing + const s = positive[0]; + out.push(``); + if (g.rIn > 0) out.push(``); + } else { + for (const s of slices()) { + if (s.a1 <= s.a0) continue; + const op = hv === null || hv === s.idx ? 1 : 0.55; + out.push(``); + } + } + return out.join(""); + }); + + const onMove = (e: PointerEvent) => { + if (p.props.tooltip === false) return; + const rect = (e.currentTarget as SVGElement).getBoundingClientRect(); + const g = geo(); + const px = e.clientX - rect.left, py = e.clientY - rect.top; + const dx = px - g.cx, dy = py - g.cy; + const dist = Math.hypot(dx, dy); + if (dist > g.rOut || (g.rIn > 0 && dist < g.rIn)) { setHover(null); return; } + // pointOnCircle maps deg (from 12 o'clock, clockwise) to (cos(deg-90), sin(deg-90)); + // invert it: the pointer's slice angle is atan2(dy,dx) shifted back by 90°. + let deg = (Math.atan2(dy, dx) * 180 / Math.PI + 90 + 360) % 360; + const s = slices().find((s) => s.value > 0 && deg >= s.a0 && deg < s.a1); + setHover(s ? s.idx : null); + setPointer([px, py]); + }; + + return ( + <> + setHover(null)} /> + +
+
+ + {labels()[hover()!] ?? ""} +
+
+ + {p.fmt(values()[hover()!] ?? 0)} + + + {total() > 0 ? ((Math.max(0, values()[hover()!] ?? 0) / total()) * 100).toFixed(1) + "%" : ""} + +
+
+ + + ); +} + +// ── legend ───────────────────────────────────────────────────────────────────── + +function Legend(p: { props: ChartProps; colorOf: (i: number) => string }): JSXElement { + // Pie/donut identity is the SLICE; cartesian identity is the SERIES. A line keys with + // a short stroke, a fill (bar/area/slice) with a swatch — the legend mirrors the mark. + const isRadial = p.props.kind === "pie" || p.props.kind === "donut"; + const isLine = p.props.kind === "line"; + const items = () => isRadial + ? (p.props.labels ?? p.props.series[0]?.data.map((_, i) => String(i + 1)) ?? []).map((name, i) => ({ name, i })) + : p.props.series.map((s, i) => ({ name: s.name, i })); + return ( +
+ {(it) => ( +
+ + }> + + + {it.name} +
+ )}
); } diff --git a/go/webui/README.md b/go/webui/README.md index 5ccb0051..a7afce1d 100644 --- a/go/webui/README.md +++ b/go/webui/README.md @@ -37,7 +37,7 @@ See the runnable **`/wasm/kit`** demo page in `cmd/kjol-web` (Layers → Kjol Wa `Menu`*, `Submenu`, `EnvBadge`, `RemoteUpdateFlash`. - **Overlays/floating:** `Modal`/`ConfirmModal`/`WizardModal`, `Toast*`, `Tooltip`/ `HoverTooltip`, `Popover`/`HoverPopover`, `Floating*`. -- **Data:** `PrettyTable`, `AutoTable`, `CellGrid`, `ReactiveChart`, `Calendar`, +- **Data:** `PrettyTable`, `AutoTable`, `CellGrid`, `Chart`, `Calendar`, `DatePicker`, `FuzzyMatch*` (+ pure matchers), `Tutorial`. - **Pure logic (no vdom):** `formatters.go` (`FormatPhoneNumber`, `FormatDate`, …) and `validation.go` (`IsEmailValid`, `CreateValidation`, …). @@ -52,6 +52,7 @@ with a `// NOTE:` in the component's file. Concretely: dropdown/menu/tooltip/mod positioning is static (not computed); outside-click / Escape / hover-delay dismissal, auto-dismiss timers, and enter/exit animations are dropped (caller-driven); `AutoTable` omits virtual scrolling, column resize/reorder, inline editing, and the formula engine; -`ReactiveChart` renders only its container (the example draws charts server-side with -go-chart); a few `Form*` controls that needed canvas/async (`FormSignaturePad`, +`Chart` renders only its container — the Solid kit's dependency-free SVG geometry is not +yet ported (the example draws charts server-side with go-chart); a few `Form*` controls +that needed canvas/async (`FormSignaturePad`, `FormAsyncCombobox`) are omitted. Everything compiles on native (SSR) and js/wasm. diff --git a/go/webui/chart.go b/go/webui/chart.go index 1b3f75e5..8b5008fb 100644 --- a/go/webui/chart.go +++ b/go/webui/chart.go @@ -2,42 +2,36 @@ package webui import "kjol/vdom" -// Port of web/kit/Chart.tsx (default export ReactiveChart). +// Placeholder port of jsruntime/uikit/Chart.tsx. // -// NOTE: The TSX wraps chart.js — it imperatively creates a `new Chart(ctx, cfg)` -// against a 2D context inside onMount, and pushes new data/options via -// createEffect. None of that (canvas 2D drawing, a JS charting lib, mount/cleanup -// lifecycles) exists in the neutral gowasm runtime, so actual chart DRAWING is out -// of scope here. This port renders the faithful container structure + Tailwind and -// an empty , keeping a props shape for API parity. The example app instead -// draws charts server-side as go-chart SVG. +// NOTE: The Solid Chart is dependency-free SVG. Its drawing is pure geometry — +// nice-scale axes, rounded columns, arc slices, a pointer-driven crosshair — none of +// which is implemented here yet. This container keeps a props shape for API parity and +// renders nothing but its box; the example app draws real charts (`app/chart.go`) as +// go-chart SVG in WebAssembly. Porting the SVG geometry to `vdom` is the obvious +// follow-up. -// Chart type identifiers (chart.js `type`), kept for API parity with the TSX -// ChartType union. Unused by this SVG-less container. +// Chart kinds, kept for API parity with the TSX ChartKind union. Unused by this +// placeholder container. const ( - ChartTypeLine = "line" - ChartTypeBar = "bar" - ChartTypeRadar = "radar" - ChartTypeDoughnut = "doughnut" - ChartTypePolarArea = "polarArea" - ChartTypeBubble = "bubble" - ChartTypePie = "pie" - ChartTypeScatter = "scatter" + ChartKindLine = "line" + ChartKindArea = "area" + ChartKindBar = "bar" + ChartKindPie = "pie" + ChartKindDonut = "donut" ) -// ReactiveChartProps mirrors the TSX props. Data and Options are accepted for API -// parity but are not rendered (no JS chart lib in the neutral runtime; see NOTE). -type ReactiveChartProps struct { - Type string - Data any - Options any - Class string +// ChartProps mirrors the TSX props loosely. Series/Labels are accepted for API +// parity but are not rendered here (no SVG geometry yet; see NOTE). +type ChartProps struct { + Kind string + Labels []string + Data any + Class string } -// ReactiveChart renders the chart container (h-full + user class) wrapping an -// empty . Drawing is out of scope — see the file NOTE. -func ReactiveChart(p ReactiveChartProps) *vdom.VNode { - return vdom.Div(vdom.Attr("class", cx("h-full", p.Class)), - vdom.Canvas(), - ) +// Chart renders the chart container (the user's box class). Drawing is not yet +// ported — see the file NOTE. +func Chart(p ChartProps) *vdom.VNode { + return vdom.Div(vdom.Attr("class", cx("relative w-full", p.Class))) }