begin new custom chart implementation for js ui
This commit is contained in:
10
.vscode/launch.json
vendored
10
.vscode/launch.json
vendored
@@ -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
16
.vscode/tasks.json
vendored
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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
|
/* 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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 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 {
|
@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
|
||||||
|
|||||||
@@ -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"><canvas></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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
6
go/cmd/kjol-web/frontend/vendor/vendor.json
vendored
6
go/cmd/kjol-web/frontend/vendor/vendor.json
vendored
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
200
go/cmd/kjol-web/opensans.css
Normal file
200
go/cmd/kjol-web/opensans.css
Normal 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;
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
go/cmd/kjol-web/wwwroot/fonts/opensans-latin-ext-italic.woff2
Normal file
BIN
go/cmd/kjol-web/wwwroot/fonts/opensans-latin-ext-italic.woff2
Normal file
Binary file not shown.
BIN
go/cmd/kjol-web/wwwroot/fonts/opensans-latin-ext-normal.woff2
Normal file
BIN
go/cmd/kjol-web/wwwroot/fonts/opensans-latin-ext-normal.woff2
Normal file
Binary file not shown.
BIN
go/cmd/kjol-web/wwwroot/fonts/opensans-latin-italic.woff2
Normal file
BIN
go/cmd/kjol-web/wwwroot/fonts/opensans-latin-italic.woff2
Normal file
Binary file not shown.
BIN
go/cmd/kjol-web/wwwroot/fonts/opensans-latin-normal.woff2
Normal file
BIN
go/cmd/kjol-web/wwwroot/fonts/opensans-latin-normal.woff2
Normal file
Binary file not shown.
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 === "&" ? "&" : 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) {
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
Labels []string
|
||||||
Data any
|
Data any
|
||||||
Options 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(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user