begin new custom chart implementation for js ui

This commit is contained in:
2026-07-16 11:45:17 -04:00
parent 13a60a90a1
commit 550e97aa9b
21 changed files with 1001 additions and 179 deletions

10
.vscode/launch.json vendored
View File

@@ -7,6 +7,11 @@
// 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
// => ../..). 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", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
@@ -16,6 +21,7 @@
"mode": "auto", "mode": "auto",
"program": "${workspaceFolder}/go/cmd/kjol-web/server", "program": "${workspaceFolder}/go/cmd/kjol-web/server",
"cwd": "${workspaceFolder}/go/cmd/kjol-web", "cwd": "${workspaceFolder}/go/cmd/kjol-web",
"env": { "GOWORK": "off" },
"args": ["-addr", ":8085"], "args": ["-addr", ":8085"],
"preLaunchTask": "kjol-web: prebuild" "preLaunchTask": "kjol-web: prebuild"
}, },
@@ -26,6 +32,7 @@
"mode": "auto", "mode": "auto",
"program": "${workspaceFolder}/go/cmd/kjol-web/server", "program": "${workspaceFolder}/go/cmd/kjol-web/server",
"cwd": "${workspaceFolder}/go/cmd/kjol-web", "cwd": "${workspaceFolder}/go/cmd/kjol-web",
"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
@@ -43,6 +50,7 @@
// 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-web/server",
"cwd": "${workspaceFolder}/go/cmd/kjol-web", "cwd": "${workspaceFolder}/go/cmd/kjol-web",
"env": { "GOWORK": "off" },
"args": ["-build"] "args": ["-build"]
}, },
{ {
@@ -52,6 +60,7 @@
"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-web",
"env": { "GOWORK": "off" },
"args": ["./app"] "args": ["./app"]
}, },
{ {
@@ -63,6 +72,7 @@
// 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-web",
"env": { "GOWORK": "off" },
"args": [ "args": [
"-app", "frontend", "-app", "frontend",
"-web", "../../jsruntime", "-web", "../../jsruntime",

16
.vscode/tasks.json vendored
View File

@@ -21,6 +21,12 @@
// 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
// (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", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
@@ -37,7 +43,7 @@
"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" }, "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web", "env": { "GOWORK": "off" } },
"problemMatcher": ["$go"], "problemMatcher": ["$go"],
"presentation": { "reveal": "silent", "panel": "shared" }, "presentation": { "reveal": "silent", "panel": "shared" },
"group": "build" "group": "build"
@@ -77,7 +83,7 @@
"-out", "wwwroot", "-out", "wwwroot",
"-gen-ts", "frontend/src/ui/generated" "-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"], "problemMatcher": ["$go"],
"presentation": { "reveal": "silent", "panel": "shared" }, "presentation": { "reveal": "silent", "panel": "shared" },
"group": "build" "group": "build"
@@ -88,7 +94,7 @@
"type": "shell", "type": "shell",
"command": "go", "command": "go",
"args": ["run", "./server"], "args": ["run", "./server"],
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" }, "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web", "env": { "GOWORK": "off" } },
"dependsOn": ["kjol-web: prebuild"], "dependsOn": ["kjol-web: prebuild"],
"isBackground": true, "isBackground": true,
"problemMatcher": { "problemMatcher": {
@@ -112,7 +118,7 @@
"type": "shell", "type": "shell",
"command": "go", "command": "go",
"args": ["run", "./server", "-build"], "args": ["run", "./server", "-build"],
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" }, "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web", "env": { "GOWORK": "off" } },
"problemMatcher": ["$go"], "problemMatcher": ["$go"],
"group": "build" "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.", "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" }, "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web", "env": { "GOWORK": "off" } },
"problemMatcher": ["$go"], "problemMatcher": ["$go"],
"group": "test" "group": "test"
} }

View File

@@ -1188,12 +1188,12 @@ func chartsSection() func() *VNode {
), ),
), ),
note("webui.ReactiveChart is a stub, and says so", note("webui.Chart is a stub, and says so",
"The Solid kit's Chart wraps chart.js: it creates a Chart against a <canvas> 2D context "+ "The Solid kit's Chart draws its own SVG — nice-scale axes, rounded columns, arc slices, "+
"on mount and pushes new data through an effect. None of that — canvas drawing, a JS "+ "a pointer-driven crosshair — with no charting library at all. That geometry has not "+
"charting library, mount lifecycles — exists in the neutral Go runtime, so the Go port "+ "been ported to the neutral Go runtime yet, so the Go port keeps the props shape for "+
"keeps the props shape for parity and renders an empty canvas. Draw with go-chart "+ "parity and renders only its box. This example draws with go-chart "+
"instead, as above. Pretending otherwise would be the one thing this site refuses to do."), "instead, as above. Pretending the port drew charts would be the one thing this site refuses to do."),
) )
} }
} }

View File

@@ -13,6 +13,46 @@
--------------------------------------------------------------------------- */ --------------------------------------------------------------------------- */
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
/* ---------------------------------------------------------------------------
Open Sans — self-hosted (files in wwwroot/fonts). One variable file per subset
carries weights 400700 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 /* App-side design tokens the webui kit references (Tailwind v4 @theme). Brand
values live with the app; the kit stays generic. values live with the app; the kit stays generic.
@@ -23,6 +63,12 @@
@theme { @theme {
--radius-default: 0.375rem; --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. /* 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 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. job is to mark the few things you can act on, not to compete with them for attention.
@@ -123,6 +169,7 @@
html { html {
background-color: var(--color-surface); background-color: var(--color-surface);
color: var(--color-ink); color: var(--color-ink);
font-family: var(--font-sans);
} }
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------

View File

@@ -9,13 +9,54 @@
What is left is only what is genuinely this app's: the brand. 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 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 Open Sans, same navy accent — so that crossing between /wasm and /js reads as
parts of ONE site rather than two demos that happen to share a domain. The 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 two sections are built by completely different pipelines; they should not look
like it. like it.
--------------------------------------------------------------------------- */ --------------------------------------------------------------------------- */
/* Open Sans — self-hosted (files in wwwroot/fonts). One variable file per subset
carries weights 400700 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 { @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 /* 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 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 sidebar highlight and the callouts are the same navy on both sides of the site rather

View File

@@ -83,7 +83,7 @@ import { CrmTabGroup, CrmSubTabGroup } from "@ui/CrmTabs";
import { Accordion, SingleAccordion } from "@ui/Accordion"; 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 ReactiveChart from "@ui/Chart"; import { Chart, ChartSeries } from "@ui/Chart";
import { ThemeToggle, useTheme } from "@ui/Theme"; import { ThemeToggle, useTheme } from "@ui/Theme";
import { Demo } from "../layout/Demo.tsx"; import { Demo } from "../layout/Demo.tsx";
@@ -1328,7 +1328,7 @@ const KIT_NAMES = [
"AutoTable", "Accordion", "Alert", "Badge", "ButtonUI", "Calendar", "CellGrid", "FormCombobox", "AutoTable", "Accordion", "Alert", "Badge", "ButtonUI", "Calendar", "CellGrid", "FormCombobox",
"DatePicker", "FormInput", "Menu", "Modal", "FormMultiSelect", "Popover", "PrettyTable", "DatePicker", "FormInput", "Menu", "Modal", "FormMultiSelect", "Popover", "PrettyTable",
"SegmentedButtons", "FormSignaturePad", "TabGroup", "ThemeToggle", "ToastProvider", "ToggleSwitch", "SegmentedButtons", "FormSignaturePad", "TabGroup", "ThemeToggle", "ToastProvider", "ToggleSwitch",
"Tooltip", "TutorialProvider", "SidebarNav", "ReactiveChart", "FuzzyMatch", "Tooltip", "TutorialProvider", "SidebarNav", "Chart", "FuzzyMatch",
]; ];
function Search() { function Search() {
@@ -1364,59 +1364,48 @@ function Search() {
// ---- charts -------------------------------------------------------------- // ---- charts --------------------------------------------------------------
const CHART_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
function Charts() { function Charts() {
const [seed, setSeed] = createSignal(0); const [seed, setSeed] = createSignal(0);
const data = () => { // Two series over the same week. seed() reshuffles them so you can watch the SVG
const base = [42, 17, 63, 28, 55, 9, 71]; // move — no teardown, no new instance, just the marks that changed.
const values = base.map((v) => (seed() === 0 ? v : Math.max(4, (v * 7 + seed() * 13) % 80))); const shuffle = (base: number[]) =>
return { base.map((v) => (seed() === 0 ? v : Math.max(4, (v * 7 + seed() * 13) % 80)));
labels: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], const requests = (): ChartSeries => ({ name: "Requests", data: shuffle([42, 17, 63, 28, 55, 9, 71]) });
datasets: [ 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]) });
label: "Requests", const pieLabels = ["Direct", "Search", "Social", "Email", "Referral"];
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",
},
],
};
};
return ( return (
<Section id="charts" title="Charts"> <Section id="charts" title="Charts">
<Prose> <Prose>
<code class="font-mono">@ui/Chart</code> wraps chart.js: it creates a chart against a canvas <code class="font-mono">@ui/Chart</code> draws SVG no charting library, nothing vendored, no{" "}
on mount and pushes new data through an effect, so changing the data updates the chart rather <code class="font-mono">&lt;canvas&gt;</code>. Because the picture is JSX it is already reactive:
than rebuilding the page. change <code class="font-mono">series</code> and Solid re-renders the marks that moved. There is
no <code class="font-mono">update()</code> to call.
</Prose> </Prose>
<Prose> <Prose>
This is the one component in the kit that is genuinely different on the other side. The Go Being SVG buys two things a canvas cannot. The marks name the theme tokens
port keeps the props shape for parity and renders an empty canvas canvas drawing, a (<code class="font-mono">var(--color-chart-1)</code> ), so the palette inverts for dark mode with
JavaScript charting library and mount lifecycles do not exist in the neutral Go runtime. Kjol the rest of the site a canvas paints pixels and cannot read a CSS variable. And every mark is a
Wasm Web draws SVG with go-chart instead, and draws it in the browser, in WebAssembly, exactly real element, so hovering shows a crosshair and one tooltip listing every series at that point.
as this one draws to a canvas here. Same place, different library. Neither side pretends
otherwise.
</Prose> </Prose>
<Panel title="Bar and doughnut, from the same data"> <Panel title="Grouped bars, a smooth area, a two-line series, and a donut from one data set">
{/* The wrapper is h-full with maintainAspectRatio:false, so the PARENT must <div class="grid gap-6 lg:grid-cols-12">
have an explicit height or the canvas collapses to nothing at all. */} <div class="lg:col-span-7">
<div class="grid gap-4 lg:grid-cols-12"> <Chart kind="bar" labels={CHART_DAYS} series={[requests(), errors()]} height={260} />
<div class="h-72 lg:col-span-7">
<ReactiveChart
type="bar"
data={data()}
options={{ plugins: { legend: { display: false } } }}
/>
</div> </div>
<div class="h-72 lg:col-span-5"> <div class="lg:col-span-5">
<ReactiveChart type="doughnut" data={data()} /> <Chart kind="donut" labels={pieLabels} series={[pieData()]} height={260} />
</div>
<div class="lg:col-span-7">
<Chart kind="area" curve="smooth" labels={CHART_DAYS} series={[requests()]} height={220} />
</div>
<div class="lg:col-span-5">
<Chart kind="line" labels={CHART_DAYS} series={[requests(), errors()]} height={220} />
</div> </div>
</div> </div>
<div class="mt-4 flex items-center gap-3"> <div class="mt-4 flex items-center gap-3">
@@ -1424,17 +1413,18 @@ function Charts() {
New data New data
</ButtonUI> </ButtonUI>
<span class="text-xs text-ink-muted"> <span class="text-xs text-ink-muted">
The chart updates in place it is not torn down and rebuilt. Every chart updates in place hover any of them for the tooltip.
</span> </span>
</div> </div>
</Panel> </Panel>
<AlertYellow header="It has to be vendored" class="mt-6"> <Prose>
<code class="font-mono">Chart.tsx</code> imports chart.js at the top level and registers One <code class="font-mono">kind</code> prop picks the form {" "}
every controller on module load. An app that uses it must vendor chart.js and{" "} <code class="font-mono">"line" | "area" | "bar" | "pie" | "donut"</code> and{" "}
<code class="font-mono">@kurkle/color</code>, or the bundle does not degrade it fails to <code class="font-mono">stacked</code>, <code class="font-mono">curve</code>,{" "}
evaluate, and you get an empty page and one line in the console. <code class="font-mono">palette</code> and <code class="font-mono">valueFormat</code> refine it.
</AlertYellow> The width is measured from the container, so a chart fills whatever column you give it.
</Prose>
</Section> </Section>
); );
} }

View File

@@ -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.", "//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": { "entrypoints": {
"pdf-lib": "pdf-lib/dist/pdf-lib.esm.js", "pdf-lib": "pdf-lib/dist/pdf-lib.esm.js",
"pdfjs-dist": "pdfjs-dist/build/pdf.mjs", "pdfjs-dist": "pdfjs-dist/build/pdf.mjs"
"chart.js": "chart.js/dist/chart.js",
"@kurkle/color": "@kurkle/color/dist/color.esm.js"
} }
} }

View File

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

View File

@@ -26,10 +26,12 @@ func esbuildDefine() map[string]string {
} }
// resolveEntryPoint returns the path (relative to frontendDir) of the // 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 { func resolveEntryPoint() string {
if _, err := os.Stat(filepath.Join(frontendDir, "src/app.tsx")); err == nil { for _, e := range []string{"src/app.tsx", "src/app.ts"} {
return "src/app.tsx" if _, err := os.Stat(filepath.Join(frontendDir, e)); err == nil {
return e
}
} }
return "src/app.js" return "src/app.js"
} }
@@ -79,8 +81,16 @@ func bundleJSEntry(entry, outName string) (bundleStats, error) {
plugins := []esbuild.Plugin{aliasPlugin(), defaultExportShimPlugin(), Plugin()} plugins := []esbuild.Plugin{aliasPlugin(), defaultExportShimPlugin(), Plugin()}
plugins = append(plugins, assetURLPlugin(), vendorManifestPlugin(entrypoints, vdirs)) 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{ result := esbuild.Build(esbuild.BuildOptions{
EntryPoints: []string{entry}, EntryPoints: []string{entryPath},
Outfile: filepath.Join(outputDir, outName), Outfile: filepath.Join(outputDir, outName),
Bundle: true, Bundle: true,
Write: true, Write: true,

View File

@@ -141,6 +141,29 @@
--color-on-warning: var(--color-white); --color-on-warning: var(--color-white);
--color-danger: var(--color-red-500); --color-danger: var(--color-red-500);
--color-on-danger: var(--color-white); --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-subtle: #1f1f24;
--color-primary-border: #3d3d45; --color-primary-border: #3d3d45;
--color-accent: #e5e5e5; --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 /* The page's own background — painted before anything mounts, and behind it

View File

@@ -1,91 +1,582 @@
import { createEffect, onCleanup, onMount } from "solid-js"; import { createMemo, createSignal, For, Show, onMount, onCleanup, JSXElement } from "solid-js";
import { Chart, registerables } from "chart.js";
// chart.js v4 is tree-shakeable and ships nothing registered by default; register // A dependency-free, reactive chart. It draws SVG — nothing is vendored, nothing is
// all controllers/elements/scales once so any chart type works (the old UMD shim // registered on a global, and there is no <canvas>.
// did this implicitly). //
Chart.register(...registerables); // 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
// <path>/<line> elements. The Go-native Solid compiler namespaces an element as SVG only
// when it is written literally inside an <svg> in the same template; a <path> produced by
// control flow (<For>, a callback) is created in the HTML namespace and never paints.
// Setting innerHTML on an <svg> parses the string in the SVG namespace — the same trick
// uikit/Icons.tsx uses. Pointer handlers ride the <svg> 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 { export interface ChartSeries {
type: ChartType; name: string;
data: unknown; data: number[];
options?: object; // 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; 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 { // The palette a component IS allowed to name — the tokens carry the theming. Referenced
destroy(): void; // by index; a ninth series is the caller's problem (fold it into "Other"), never a
update(): void; // synthesised ninth hue.
data: unknown; const CHART_TOKENS = [
options: object; "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 === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;"));
// 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) { function linePathD(pts: [number, number][]): string {
let canvasRef: HTMLCanvasElement | undefined; if (!pts.length) return "";
let chartInstance: ChartInstance | null = null; 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(() => { onMount(() => {
// Defer initialization until the canvas is connected to the document. if (props.width != null || !wrap) return;
// @solidjs/router creates route components before inserting them into const read = () => { if (wrap && wrap.clientWidth > 0) setMeasured(wrap.clientWidth); };
// the DOM, and Chart.js needs getComputedStyle which requires a read();
// connected element with ownerDocument.defaultView. if (typeof ResizeObserver === "undefined") return;
const init = () => { const ro = new ResizeObserver(read);
if (!canvasRef) return; ro.observe(wrap);
if (!canvasRef.isConnected) { onCleanup(() => ro.disconnect());
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();
}); });
onCleanup(() => { const isRadial = () => props.kind === "pie" || props.kind === "donut";
if (chartInstance) { const colorOf = (i: number) =>
chartInstance.destroy(); props.series[i]?.color ?? props.palette?.[i % (props.palette.length || 1)] ?? CHART_TOKENS[i % CHART_TOKENS.length];
chartInstance = null; const fmt = (v: number) => (props.valueFormat ?? defaultFormat)(v);
} const showLegend = () => props.legend ?? (isRadial() || props.series.length > 1);
});
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();
}
});
return ( return (
<div class={"h-full " + (props.class || "")}> <div ref={wrap} class={"relative w-full" + (props.class ? " " + props.class : "")}>
<canvas ref={(el: HTMLCanvasElement) => canvasRef = el}></canvas> <Show when={isRadial()} fallback={
<CartesianChart props={props} width={width()} height={height()} colorOf={colorOf} fmt={fmt} />
}>
<RadialChart props={props} width={width()} height={height()} colorOf={colorOf} fmt={fmt} />
</Show>
<Show when={showLegend()}>
<Legend props={props} colorOf={colorOf} />
</Show>
</div>
);
}
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<number | null>(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(`<line x1="${l.left}" x2="${l.left + l.plotW}" y1="${y}" y2="${y}" stroke="var(--color-line)" stroke-width="1"/>`);
if (showAxes()) out.push(`<text x="${l.left - 8}" y="${y}" text-anchor="end" dominant-baseline="middle" fill="var(--color-ink-faint)" style="font-size:11px;font-variant-numeric:tabular-nums">${esc(p.fmt(t))}</text>`);
}
const by = baselineY();
out.push(`<line x1="${l.left}" x2="${l.left + l.plotW}" y1="${by}" y2="${by}" stroke="var(--color-line-strong)" stroke-width="1"/>`);
if (showAxes()) {
labels().forEach((lab, i) =>
out.push(`<text x="${bandCenter(i)}" y="${p.height - 8}" text-anchor="middle" fill="var(--color-ink-faint)" style="font-size:11px">${esc(lab)}</text>`));
}
// crosshair (line/area only — a bar reader aims at a bar, not a hairline)
if (p.props.tooltip !== false && hv !== null && !isBar()) {
const x = bandCenter(hv);
out.push(`<line x1="${x}" x2="${x}" y1="${l.top}" y2="${l.top + l.plotH}" stroke="var(--color-line-strong)" stroke-width="1"/>`);
}
for (const b of bars()) {
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}"/>`);
}
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"/>`);
}
for (const pth of paths()) {
out.push(`<path d="${pth.line}" fill="none" stroke="${esc(p.colorOf(pth.seriesIdx))}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>`);
}
// 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(`<circle cx="${pt[0]}" cy="${pt[1]}" r="${MARK_R}" fill="${esc(p.colorOf(pth.seriesIdx))}" stroke="var(--color-surface)" stroke-width="2"/>`);
}
}
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 (
<>
<svg width={p.width} height={p.height} viewBox={`0 0 ${p.width} ${p.height}`} class="block overflow-visible"
role="img" innerHTML={body()} onpointermove={onMove} onpointerleave={() => setHover(null)} />
<Show when={p.props.tooltip !== false && hover() !== null}>
<CartesianTooltip
props={p.props} colorOf={p.colorOf} fmt={p.fmt}
index={hover()!} label={labels()[hover()!] ?? ""}
anchorX={bandCenter(hover()!)} anchorY={pointerY()} width={p.width} height={p.height} />
</Show>
</>
);
}
interface BarMark { x: number; w: number; yBase: number; yVal: number; seriesIdx: number; catIdx: number; value: number; round: boolean; }
interface LineMark { seriesIdx: number; line: string; area: string; pts: [number, number][]; }
function 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 (
<div class="pointer-events-none absolute z-10 min-w-32 max-w-64 rounded-default border border-line bg-surface px-3 py-2 text-xs shadow-lg"
style={style()}>
<div class="mb-1 font-medium text-ink">{p.label}</div>
<For each={p.props.series}>{(s, i) => (
<div class="flex items-center gap-2 leading-relaxed">
<span class="inline-block h-2.5 w-2.5 shrink-0 rounded-xs" style={{ "background-color": p.colorOf(i()) }} />
<span class="text-ink-muted">{s.name}</span>
<span class="ml-auto font-semibold text-ink" style={{ "font-variant-numeric": "tabular-nums" }}>
{p.fmt(s.data[p.index] ?? 0)}
</span>
</div>
)}</For>
</div>
);
}
// ── radial (pie / donut) ──────────────────────────────────────────────────────────
function RadialChart(p: SubProps): JSXElement {
const [hover, setHover] = createSignal<number | null>(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(`<circle cx="${g.cx}" cy="${g.cy}" r="${g.rOut}" fill="${esc(p.colorOf(s.idx))}"/>`);
if (g.rIn > 0) out.push(`<circle cx="${g.cx}" cy="${g.cy}" r="${g.rIn}" fill="var(--color-surface)"/>`);
} else {
for (const s of slices()) {
if (s.a1 <= s.a0) continue;
const op = hv === null || hv === s.idx ? 1 : 0.55;
out.push(`<path d="${slicePathD(g.cx, g.cy, g.rOut, g.rIn, s.a0, s.a1)}" fill="${esc(p.colorOf(s.idx))}" fill-opacity="${op}" stroke="var(--color-surface)" stroke-width="${SEG_GAP}"/>`);
}
}
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 (
<>
<svg width={p.width} height={p.height} viewBox={`0 0 ${p.width} ${p.height}`} class="block" role="img"
innerHTML={body()} onpointermove={onMove} onpointerleave={() => setHover(null)} />
<Show when={p.props.tooltip !== false && hover() !== null}>
<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, p.width - 8)}px`,
top: `${clamp(pointer()[1], 8, p.height - 8)}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": p.colorOf(hover()!) }} />
<span class="text-ink-muted">{labels()[hover()!] ?? ""}</span>
</div>
<div class="mt-1 flex items-baseline gap-2">
<span class="font-semibold text-ink" style={{ "font-variant-numeric": "tabular-nums" }}>
{p.fmt(values()[hover()!] ?? 0)}
</span>
<span class="text-ink-faint">
{total() > 0 ? ((Math.max(0, values()[hover()!] ?? 0) / total()) * 100).toFixed(1) + "%" : ""}
</span>
</div>
</div>
</Show>
</>
);
}
// ── 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 (
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5">
<For each={items()}>{(it) => (
<div class="flex items-center gap-1.5">
<Show when={isLine} fallback={
<span class="inline-block h-2.5 w-2.5 rounded-xs" style={{ "background-color": p.colorOf(it.i) }} />
}>
<span class="inline-block h-0.5 w-4 rounded-full" style={{ "background-color": p.colorOf(it.i) }} />
</Show>
<span class="text-xs text-ink-soft">{it.name}</span>
</div>
)}</For>
</div> </div>
); );
} }

View File

@@ -37,7 +37,7 @@ See the runnable **`/wasm/kit`** demo page in `cmd/kjol-web` (Layers → Kjol Wa
`Menu`*, `Submenu`, `EnvBadge`, `RemoteUpdateFlash`. `Menu`*, `Submenu`, `EnvBadge`, `RemoteUpdateFlash`.
- **Overlays/floating:** `Modal`/`ConfirmModal`/`WizardModal`, `Toast*`, `Tooltip`/ - **Overlays/floating:** `Modal`/`ConfirmModal`/`WizardModal`, `Toast*`, `Tooltip`/
`HoverTooltip`, `Popover`/`HoverPopover`, `Floating*`. `HoverTooltip`, `Popover`/`HoverPopover`, `Floating*`.
- **Data:** `PrettyTable`, `AutoTable`, `CellGrid`, `ReactiveChart`, `Calendar`, - **Data:** `PrettyTable`, `AutoTable`, `CellGrid`, `Chart`, `Calendar`,
`DatePicker`, `FuzzyMatch*` (+ pure matchers), `Tutorial`. `DatePicker`, `FuzzyMatch*` (+ pure matchers), `Tutorial`.
- **Pure logic (no vdom):** `formatters.go` (`FormatPhoneNumber`, `FormatDate`, …) and - **Pure logic (no vdom):** `formatters.go` (`FormatPhoneNumber`, `FormatDate`, …) and
`validation.go` (`IsEmailValid`, `CreateValidation`, …). `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, positioning is static (not computed); outside-click / Escape / hover-delay dismissal,
auto-dismiss timers, and enter/exit animations are dropped (caller-driven); `AutoTable` auto-dismiss timers, and enter/exit animations are dropped (caller-driven); `AutoTable`
omits virtual scrolling, column resize/reorder, inline editing, and the formula engine; omits virtual scrolling, column resize/reorder, inline editing, and the formula engine;
`ReactiveChart` renders only its container (the example draws charts server-side with `Chart` renders only its container the Solid kit's dependency-free SVG geometry is not
go-chart); a few `Form*` controls that needed canvas/async (`FormSignaturePad`, 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. `FormAsyncCombobox`) are omitted. Everything compiles on native (SSR) and js/wasm.

View File

@@ -2,42 +2,36 @@ package webui
import "kjol/vdom" 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)` // NOTE: The Solid Chart is dependency-free SVG. Its drawing is pure geometry —
// against a <canvas> 2D context inside onMount, and pushes new data/options via // nice-scale axes, rounded columns, arc slices, a pointer-driven crosshair — none of
// createEffect. None of that (canvas 2D drawing, a JS charting lib, mount/cleanup // which is implemented here yet. This container keeps a props shape for API parity and
// lifecycles) exists in the neutral gowasm runtime, so actual chart DRAWING is out // renders nothing but its box; the example app draws real charts (`app/chart.go`) as
// of scope here. This port renders the faithful container structure + Tailwind and // go-chart SVG in WebAssembly. Porting the SVG geometry to `vdom` is the obvious
// an empty <canvas>, keeping a props shape for API parity. The example app instead // follow-up.
// draws charts server-side as go-chart SVG.
// Chart type identifiers (chart.js `type`), kept for API parity with the TSX // Chart kinds, kept for API parity with the TSX ChartKind union. Unused by this
// ChartType union. Unused by this SVG-less container. // placeholder container.
const ( const (
ChartTypeLine = "line" ChartKindLine = "line"
ChartTypeBar = "bar" ChartKindArea = "area"
ChartTypeRadar = "radar" ChartKindBar = "bar"
ChartTypeDoughnut = "doughnut" ChartKindPie = "pie"
ChartTypePolarArea = "polarArea" ChartKindDonut = "donut"
ChartTypeBubble = "bubble"
ChartTypePie = "pie"
ChartTypeScatter = "scatter"
) )
// ReactiveChartProps mirrors the TSX props. Data and Options are accepted for API // ChartProps mirrors the TSX props loosely. Series/Labels are accepted for API
// parity but are not rendered (no JS chart lib in the neutral runtime; see NOTE). // parity but are not rendered here (no SVG geometry yet; see NOTE).
type ReactiveChartProps struct { type ChartProps struct {
Type string Kind string
Data any Labels []string
Options any Data any
Class string Class string
} }
// ReactiveChart renders the chart container (h-full + user class) wrapping an // Chart renders the chart container (the user's box class). Drawing is not yet
// empty <canvas>. Drawing is out of scope — see the file NOTE. // ported — see the file NOTE.
func ReactiveChart(p ReactiveChartProps) *vdom.VNode { func Chart(p ChartProps) *vdom.VNode {
return vdom.Div(vdom.Attr("class", cx("h-full", p.Class)), return vdom.Div(vdom.Attr("class", cx("relative w-full", p.Class)))
vdom.Canvas(),
)
} }