queria 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -1
  3. package/dist/boot.d.ts +24 -0
  4. package/dist/boot.js +137 -0
  5. package/dist/boot.js.map +1 -0
  6. package/dist/client.d.ts +56 -0
  7. package/dist/client.js +194 -0
  8. package/dist/client.js.map +1 -0
  9. package/dist/elements/define.d.ts +1 -0
  10. package/dist/elements/define.js +4 -0
  11. package/dist/elements/define.js.map +1 -0
  12. package/dist/elements/index.d.ts +40 -0
  13. package/dist/elements/index.js +144 -0
  14. package/dist/elements/index.js.map +1 -0
  15. package/dist/index.d.ts +3 -0
  16. package/dist/index.js +4 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/react/Chart.d.ts +12 -0
  19. package/dist/react/Chart.js +42 -0
  20. package/dist/react/Chart.js.map +1 -0
  21. package/dist/react/DataMap.d.ts +12 -0
  22. package/dist/react/DataMap.js +44 -0
  23. package/dist/react/DataMap.js.map +1 -0
  24. package/dist/react/MapView.d.ts +13 -0
  25. package/dist/react/MapView.js +31 -0
  26. package/dist/react/MapView.js.map +1 -0
  27. package/dist/react/PoweredBy.d.ts +2 -0
  28. package/dist/react/PoweredBy.js +5 -0
  29. package/dist/react/PoweredBy.js.map +1 -0
  30. package/dist/react/index.d.ts +5 -0
  31. package/dist/react/index.js +6 -0
  32. package/dist/react/index.js.map +1 -0
  33. package/dist/react/query.d.ts +20 -0
  34. package/dist/react/query.js +50 -0
  35. package/dist/react/query.js.map +1 -0
  36. package/dist/render/attribution.d.ts +6 -0
  37. package/dist/render/attribution.js +17 -0
  38. package/dist/render/attribution.js.map +1 -0
  39. package/dist/render/chart.d.ts +27 -0
  40. package/dist/render/chart.js +50 -0
  41. package/dist/render/chart.js.map +1 -0
  42. package/dist/render/data-map.d.ts +61 -0
  43. package/dist/render/data-map.js +200 -0
  44. package/dist/render/data-map.js.map +1 -0
  45. package/dist/render/escape.d.ts +2 -0
  46. package/dist/render/escape.js +4 -0
  47. package/dist/render/escape.js.map +1 -0
  48. package/dist/render/index.d.ts +5 -0
  49. package/dist/render/index.js +6 -0
  50. package/dist/render/index.js.map +1 -0
  51. package/dist/render/map.d.ts +7 -0
  52. package/dist/render/map.js +9 -0
  53. package/dist/render/map.js.map +1 -0
  54. package/dist/render/poweredBy.d.ts +2 -0
  55. package/dist/render/poweredBy.js +5 -0
  56. package/dist/render/poweredBy.js.map +1 -0
  57. package/dist/sql.d.ts +10 -0
  58. package/dist/sql.js +13 -0
  59. package/dist/sql.js.map +1 -0
  60. package/package.json +93 -1
  61. package/src/boot.test.ts +65 -0
  62. package/src/boot.ts +141 -0
  63. package/src/client.test.ts +77 -0
  64. package/src/client.ts +247 -0
  65. package/src/elements/define.ts +4 -0
  66. package/src/elements/index.ts +151 -0
  67. package/src/index.ts +12 -0
  68. package/src/react/Chart.tsx +49 -0
  69. package/src/react/DataMap.tsx +52 -0
  70. package/src/react/MapView.tsx +45 -0
  71. package/src/react/PoweredBy.tsx +4 -0
  72. package/src/react/index.ts +5 -0
  73. package/src/react/query.tsx +64 -0
  74. package/src/render/attribution.test.ts +22 -0
  75. package/src/render/attribution.ts +21 -0
  76. package/src/render/chart.test.ts +23 -0
  77. package/src/render/chart.ts +80 -0
  78. package/src/render/data-map.test.ts +40 -0
  79. package/src/render/data-map.ts +230 -0
  80. package/src/render/escape.ts +4 -0
  81. package/src/render/index.ts +12 -0
  82. package/src/render/map.ts +18 -0
  83. package/src/render/poweredBy.ts +6 -0
  84. package/src/sql.test.ts +21 -0
  85. package/src/sql.ts +19 -0
@@ -0,0 +1,49 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { Dataset } from "../client.js";
3
+ import { attributionHtml } from "../render/attribution.js";
4
+ import { type ChartSpec, renderChart } from "../render/chart.js";
5
+ import { useQueria } from "./query.js";
6
+
7
+ type Props = { spec: ChartSpec; className?: string } & ({ sql: string } | { rows: readonly Record<string, unknown>[] });
8
+
9
+ /** A Vega-Lite chart of the SQL result (or of `rows`), followed by the sources of the datasets. */
10
+ export const Chart = ({ spec, className = "queria-chart", ...data }: Props) => {
11
+ const conn = useQueria();
12
+ const el = useRef<HTMLDivElement>(null);
13
+ const [state, setState] = useState<{ sources: Dataset[]; error?: unknown }>({ sources: [] });
14
+ const sql = "sql" in data ? data.sql : null;
15
+ const rows = "rows" in data ? data.rows : null;
16
+ // Compare the spec by content: a spec written inline is a new object on every render of the parent.
17
+ const specKey = JSON.stringify(spec);
18
+
19
+ useEffect(() => {
20
+ let alive = true;
21
+ let finalize: (() => void) | undefined;
22
+ const load = sql === null ? Promise.all([rows ?? [], conn.sources()]) : Promise.all([conn.sql(sql), conn.sources(sql)]);
23
+ load
24
+ .then(async ([values, sources]) => {
25
+ if (!alive || !el.current) return;
26
+ setState({ sources });
27
+ ({ finalize } = await renderChart(el.current, spec, values));
28
+ if (!alive) finalize();
29
+ })
30
+ .catch((error: unknown) => {
31
+ if (!alive) return;
32
+ // finalize stops the previous chart but leaves its drawing in the element.
33
+ el.current?.replaceChildren();
34
+ setState({ sources: [], error });
35
+ });
36
+ return () => {
37
+ alive = false;
38
+ finalize?.();
39
+ };
40
+ }, [conn, sql, rows, specKey]);
41
+
42
+ return (
43
+ <div className={className}>
44
+ {state.error ? <p className="queria-note">{String(state.error)}</p> : null}
45
+ <div ref={el} />
46
+ <div dangerouslySetInnerHTML={{ __html: attributionHtml(state.sources) }} />
47
+ </div>
48
+ );
49
+ };
@@ -0,0 +1,52 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { Dataset } from "../client.js";
3
+ import { attributionHtml } from "../render/attribution.js";
4
+ import { type MapLayer, type MapRenderOptions, renderMap } from "../render/data-map.js";
5
+ import { useQueria } from "./query.js";
6
+
7
+ type Props = { layer: MapLayer; className?: string } & MapRenderOptions &
8
+ ({ sql: string } | { rows: readonly Record<string, unknown>[] });
9
+
10
+ /** The SQL result (or `rows`) on a map by area code or by lng/lat, followed by the sources. Its height is the CSS variable --queria-map-height (400px by default). */
11
+ export const DataMap = ({ layer, className = "queria-map", style, theme, ...data }: Props) => {
12
+ const conn = useQueria();
13
+ const el = useRef<HTMLDivElement>(null);
14
+ const [state, setState] = useState<{ sources: Dataset[]; error?: unknown }>({ sources: [] });
15
+ const sql = "sql" in data ? data.sql : null;
16
+ const rows = "rows" in data ? data.rows : null;
17
+ // Inline objects are new on every render of the parent, so compare them by content.
18
+ const key = JSON.stringify([layer, style, theme]);
19
+
20
+ useEffect(() => {
21
+ let alive = true;
22
+ let finalize: (() => void) | undefined;
23
+ // Each map gets its own element: a map removed after the next one started takes its class off its element.
24
+ const box = document.createElement("div");
25
+ box.style.height = "100%";
26
+ el.current?.append(box);
27
+ const load = sql === null ? Promise.all([rows ?? [], conn.sources()]) : Promise.all([conn.sql(sql), conn.sources(sql)]);
28
+ load
29
+ .then(async ([values, sources]) => {
30
+ if (!alive) return;
31
+ setState({ sources });
32
+ ({ finalize } = await renderMap(box, layer, values, { style, theme }));
33
+ if (!alive) finalize();
34
+ })
35
+ .catch((error: unknown) => {
36
+ if (alive) setState({ sources: [], error });
37
+ });
38
+ return () => {
39
+ alive = false;
40
+ finalize?.();
41
+ box.remove();
42
+ };
43
+ }, [conn, sql, rows, key]);
44
+
45
+ return (
46
+ <div className={className}>
47
+ {state.error ? <p className="queria-note">{String(state.error)}</p> : null}
48
+ <div ref={el} style={{ height: "var(--queria-map-height, 400px)" }} />
49
+ <div dangerouslySetInnerHTML={{ __html: attributionHtml(state.sources) }} />
50
+ </div>
51
+ );
52
+ };
@@ -0,0 +1,45 @@
1
+ import type { Map, MapOptions, StyleSpecification } from "maplibre-gl";
2
+ import { type ReactNode, useEffect, useRef } from "react";
3
+ import { type MapHandle, mountMap } from "../render/map.js";
4
+
5
+ type Props = {
6
+ style: StyleSpecification | string;
7
+ /** Called once when the map is created. Attach handlers here and return a cleanup. */
8
+ onReady?: (map: Map) => void | (() => void);
9
+ options?: Omit<MapOptions, "container" | "style">;
10
+ className?: string;
11
+ children?: ReactNode;
12
+ };
13
+
14
+ /** A MapLibre map. Passing a new style applies only the diff. */
15
+ export const MapView = ({ style, onReady, options, className = "queria-map", children }: Props) => {
16
+ const container = useRef<HTMLDivElement>(null);
17
+ const handle = useRef<MapHandle | null>(null);
18
+
19
+ useEffect(() => {
20
+ if (!container.current) return;
21
+ const h = mountMap(container.current, style, options);
22
+ handle.current = h;
23
+ const cleanup = h.map.then((m) => onReady?.(m));
24
+ return () => {
25
+ cleanup.then((f) => f?.(), console.error);
26
+ h.destroy();
27
+ handle.current = null;
28
+ };
29
+ // Create the map once; style changes are applied below.
30
+ }, []);
31
+
32
+ // The map is created with the first style; apply only later changes.
33
+ const applied = useRef(style);
34
+ useEffect(() => {
35
+ if (applied.current === style) return;
36
+ applied.current = style;
37
+ handle.current?.map.then((m) => m.setStyle(style), console.error);
38
+ }, [style]);
39
+
40
+ return (
41
+ <div className={className} ref={container}>
42
+ {children}
43
+ </div>
44
+ );
45
+ };
@@ -0,0 +1,4 @@
1
+ import { poweredByHtml } from "../render/poweredBy.js";
2
+
3
+ /** A link that shows the app is built on Queria. */
4
+ export const PoweredBy = () => <span dangerouslySetInnerHTML={{ __html: poweredByHtml() }} />;
@@ -0,0 +1,5 @@
1
+ export { QueriaProvider, useQueria, useQuery, type QueryState } from "./query.js";
2
+ export { Chart } from "./Chart.js";
3
+ export { DataMap } from "./DataMap.js";
4
+ export { MapView } from "./MapView.js";
5
+ export { PoweredBy } from "./PoweredBy.js";
@@ -0,0 +1,64 @@
1
+ import { createContext, type ReactNode, useContext, useEffect, useMemo, useRef, useState } from "react";
2
+ import { type Connection, type ConnectOptions, connect } from "../client.js";
3
+
4
+ const Context = createContext<Connection | null>(null);
5
+
6
+ /** Declare the datasets for the components below: <QueriaProvider datasets={["queria/e_stat"]}>. */
7
+ export const QueriaProvider = ({ children, ...options }: ConnectOptions & { children: ReactNode }) => {
8
+ // JSON drops functions, so a token function is keyed by its presence and called through a ref:
9
+ // a new function on each render keeps the connection, and the latest one is called.
10
+ const { token } = options;
11
+ const latest = useRef(token);
12
+ latest.current = token;
13
+ const key = JSON.stringify({ ...options, token: typeof token === "function" ? "function" : token });
14
+ const conn = useMemo(
15
+ () =>
16
+ connect({
17
+ ...options,
18
+ token:
19
+ typeof token === "function"
20
+ ? async () => {
21
+ const t = latest.current;
22
+ if (t === undefined) throw new Error("The token was removed");
23
+ return typeof t === "function" ? t() : t;
24
+ }
25
+ : token,
26
+ }),
27
+ [key],
28
+ );
29
+ return <Context.Provider value={conn}>{children}</Context.Provider>;
30
+ };
31
+
32
+ export const useQueria = (): Connection => {
33
+ const conn = useContext(Context);
34
+ if (!conn) throw new Error("Wrap the component in <QueriaProvider datasets={[...]}>");
35
+ return conn;
36
+ };
37
+
38
+ export type QueryState<T> =
39
+ | { status: "idle" }
40
+ | { status: "loading" }
41
+ | { status: "done"; rows: T[] }
42
+ | { status: "error"; error: unknown };
43
+
44
+ /** Run SQL and re-run it when the SQL changes. Pass null to skip; the state is then idle. */
45
+ export const useQuery = <T = Record<string, unknown>,>(sql: string | null): QueryState<T> => {
46
+ const conn = useQueria();
47
+ const [state, setState] = useState<QueryState<T>>(sql === null ? { status: "idle" } : { status: "loading" });
48
+ useEffect(() => {
49
+ if (sql === null) {
50
+ setState({ status: "idle" });
51
+ return;
52
+ }
53
+ let alive = true;
54
+ setState({ status: "loading" });
55
+ conn.sql<T>(sql).then(
56
+ (rows) => alive && setState({ status: "done", rows }),
57
+ (error: unknown) => alive && setState({ status: "error", error }),
58
+ );
59
+ return () => {
60
+ alive = false;
61
+ };
62
+ }, [conn, sql]);
63
+ return state;
64
+ };
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { attributionHtml } from "./attribution.js";
3
+
4
+ describe("attributionHtml", () => {
5
+ it("lists each dataset with its licenses", () => {
6
+ const html = attributionHtml([
7
+ {
8
+ qualified_name: "queria/e_stat",
9
+ title: "e-Stat",
10
+ page_url: "https://queria.io/datasets/queria/e_stat",
11
+ licenses: [{ id: "CC-BY-4.0", title: "CC BY 4.0", url: "https://creativecommons.org/licenses/by/4.0/" }],
12
+ catalog_url: "u",
13
+ },
14
+ ]);
15
+ expect(html).toContain('href="https://queria.io/datasets/queria/e_stat"');
16
+ expect(html).toContain(">CC BY 4.0</a>");
17
+ });
18
+ it("does not link non-http URLs", () => {
19
+ expect(attributionHtml([{ qualified_name: "a/b", page_url: "javascript:x", catalog_url: "u" }])).not.toContain("href");
20
+ });
21
+ it("is empty without datasets", () => expect(attributionHtml([])).toBe(""));
22
+ });
@@ -0,0 +1,21 @@
1
+ import type { Dataset } from "../client.js";
2
+ import { esc } from "./escape.js";
3
+
4
+ const link = (url: string | undefined, text: string): string =>
5
+ url && /^https?:\/\//.test(url) ? `<a href="${esc(url)}" target="_blank" rel="noopener">${esc(text)}</a>` : esc(text);
6
+
7
+ /**
8
+ * Markup of the sources and licenses of the datasets, for display under a chart or map.
9
+ * Licenses such as CC BY require it wherever the data is shown.
10
+ */
11
+ export const attributionHtml = (datasets: readonly Dataset[]): string =>
12
+ datasets.length === 0
13
+ ? ""
14
+ : '<p class="queria-attribution">Source: ' +
15
+ datasets
16
+ .map((d) => {
17
+ const licenses = (d.licenses ?? []).map((l) => link(l.url, l.title ?? l.id ?? "")).filter(Boolean);
18
+ return link(d.page_url, d.title ?? d.qualified_name) + (licenses.length ? ` (${licenses.join(", ")})` : "");
19
+ })
20
+ .join("; ") +
21
+ "</p>";
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { withData } from "./chart.js";
3
+
4
+ describe("withData", () => {
5
+ it("puts rows into a spec without data", () => {
6
+ expect(withData({ mark: "line" }, [{ x: 1 }])).toEqual({ width: "container", mark: "line", data: { values: [{ x: 1 }] } });
7
+ });
8
+ it("passes named rows as datasets for data: { name }", () => {
9
+ const spec = { layer: [{ mark: "line", data: { name: "a" } }, { mark: "point", data: { name: "b" } }] };
10
+ expect(withData(spec, { a: [{ x: 1 }], b: [{ x: 2 }] })).toMatchObject({ datasets: { a: [{ x: 1 }], b: [{ x: 2 }] } });
11
+ });
12
+ it("keeps the spec's own datasets", () => {
13
+ expect(withData({ datasets: { c: [{ x: 3 }] }, mark: "line" }, { a: [{ x: 1 }] })).toMatchObject({
14
+ datasets: { c: [{ x: 3 }], a: [{ x: 1 }] },
15
+ });
16
+ });
17
+ it("copies rows and leaves width alone for composite views", () => {
18
+ const rows = [Object.freeze({ x: 1 })];
19
+ const out = withData({ hconcat: [] }, rows);
20
+ expect(out).not.toHaveProperty("width");
21
+ expect(Object.isFrozen((out.data as { values: object[] }).values[0])).toBe(false);
22
+ });
23
+ });
@@ -0,0 +1,80 @@
1
+ import type { EmbedOptions, Result, VisualizationSpec } from "vega-embed";
2
+
3
+ /** A Vega-Lite spec. Its data is put in by the package. */
4
+ export type ChartSpec = VisualizationSpec | Record<string, unknown>;
5
+
6
+ export type Rows = readonly Record<string, unknown>[];
7
+
8
+ // Vega writes an id into every row it reads, so hand it copies: the caller's rows may be frozen or shared.
9
+ const copy = (rows: Rows): Record<string, unknown>[] => rows.map((r) => ({ ...r }));
10
+
11
+ const isRows = (d: Rows | Readonly<Record<string, Rows>>): d is Rows => Array.isArray(d);
12
+
13
+ const ownDatasets = (spec: ChartSpec): object => {
14
+ const d: unknown = "datasets" in spec ? spec.datasets : undefined;
15
+ return typeof d === "object" && d !== null ? d : {};
16
+ };
17
+
18
+ /**
19
+ * Put the data into the spec. With rows, they become the data of a spec that has none.
20
+ * With named rows, the spec (or its layers) picks them with data: { name }; rows named "" are the default data.
21
+ */
22
+ export const withData = (spec: ChartSpec, data: Rows | Readonly<Record<string, Rows>>): Record<string, unknown> => {
23
+ const named = isRows(data) ? { "": data } : data;
24
+ const { "": main, ...rest } = named;
25
+ // Vega-Lite takes width "container" only for single and layered views, and warns on the others.
26
+ const composite = ["facet", "repeat", "concat", "hconcat", "vconcat"].some((k) => k in spec);
27
+ return {
28
+ ...(composite ? {} : { width: "container" }),
29
+ ...spec,
30
+ ...(main && !("data" in spec) ? { data: { values: copy(main) } } : {}),
31
+ // The spec's own datasets stay; named rows are laid over them.
32
+ ...(Object.keys(rest).length
33
+ ? { datasets: { ...ownDatasets(spec), ...Object.fromEntries(Object.entries(rest).map(([k, v]) => [k, copy(v)])) } }
34
+ : {}),
35
+ };
36
+ };
37
+
38
+ export type ChartOptions = {
39
+ /** Vega-Lite config, such as a theme. The spec's own config is merged over it. */
40
+ config?: Record<string, unknown>;
41
+ /** Export and source menu. Hidden by default. */
42
+ actions?: EmbedOptions["actions"];
43
+ /** Labels of the menu. */
44
+ i18n?: EmbedOptions["i18n"];
45
+ tooltipTheme?: "light" | "dark";
46
+ /** By default svg, and canvas when there are more than 5,000 rows in all. */
47
+ renderer?: "svg" | "canvas";
48
+ };
49
+
50
+ const CANVAS_ABOVE = 5000;
51
+
52
+ const rowCount = (data: Rows | Readonly<Record<string, Rows>>): number =>
53
+ isRows(data) ? data.length : Object.values(data).reduce((n, rows) => n + rows.length, 0);
54
+
55
+ /**
56
+ * Draw a Vega-Lite chart. vega-embed and vega-interpreter are loaded on first use. Expressions are run by the
57
+ * interpreter, so charts draw on pages whose Content Security Policy has no unsafe-eval.
58
+ * Returns the Vega view and a function that removes the chart.
59
+ * Sources and licenses are not drawn here; the elements and React components add them under the chart.
60
+ */
61
+ export const renderChart = async (
62
+ el: HTMLElement,
63
+ spec: ChartSpec,
64
+ data: Rows | Readonly<Record<string, Rows>>,
65
+ { config, actions = false, i18n, tooltipTheme, renderer }: ChartOptions = {},
66
+ ): Promise<Pick<Result, "view" | "finalize">> => {
67
+ const [{ default: embed }, { expressionInterpreter }] = await Promise.all([import("vega-embed"), import("vega-interpreter")]);
68
+ const { view, finalize } = await embed(el, withData(spec, data) as VisualizationSpec, {
69
+ // Read the spec as Vega-Lite even when its $schema says Vega
70
+ mode: "vega-lite",
71
+ ast: true,
72
+ expr: expressionInterpreter,
73
+ renderer: renderer ?? (rowCount(data) > CANVAS_ABOVE ? "canvas" : "svg"),
74
+ actions,
75
+ ...(i18n ? { i18n } : {}),
76
+ ...(config ? { config } : {}),
77
+ ...(tooltipTheme ? { tooltip: { theme: tooltipTheme } } : {}),
78
+ });
79
+ return { view, finalize };
80
+ };
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { areaCode, areaRows, colorsOf, pointFeatures } from "./data-map.js";
3
+
4
+ describe("areaCode", () => {
5
+ it("restores leading zeros and drops the check digit", () => {
6
+ expect(areaCode("prefecture", 1)).toBe("01");
7
+ expect(areaCode("city", 13101)).toBe("13101");
8
+ expect(areaCode("city", "131016")).toBe("13101");
9
+ expect(areaCode("city", 1101)).toBe("01101");
10
+ expect(areaCode("small_area", 1101000101)).toBe("01101000101");
11
+ expect(areaCode("city", null)).toBeNull();
12
+ });
13
+ });
14
+
15
+ describe("colorsOf", () => {
16
+ it("runs numbers from light to dark", () => {
17
+ expect(colorsOf([{ v: 0 }, { v: 10 }], "v")).toEqual(["#dbeafe", "#1d4ed8"]);
18
+ });
19
+ it("gives each category its own color", () => {
20
+ const [a, b, c] = colorsOf([{ v: "x" }, { v: "y" }, { v: "x" }], "v");
21
+ expect(a).toBe(c);
22
+ expect(a).not.toBe(b);
23
+ });
24
+ it("uses one color without a column", () => expect(new Set(colorsOf([{}, {}], undefined)).size).toBe(1));
25
+ });
26
+
27
+ describe("pointFeatures", () => {
28
+ it("leaves out rows without a position", () => {
29
+ const data = pointFeatures([{ x: 139.7, y: 35.6, n: "a" }, { x: null, y: 35 }, { x: "", y: 35 }], { lng: "x", lat: "y", label: "n" });
30
+ expect(data).toMatchObject({ features: [{ geometry: { coordinates: [139.7, 35.6] }, properties: { hover: "a" } }] });
31
+ });
32
+ });
33
+
34
+ describe("areaRows", () => {
35
+ it("keys rows by the tile code, first row wins", () => {
36
+ const areas = areaRows([{ c: 13101, p: 1 }, { c: "131016", p: 2 }], { area: "city", code: "c", color: "p" });
37
+ expect([...areas.keys()]).toEqual(["13101"]);
38
+ expect(areas.get("13101")?.hover).toBe("p: 1");
39
+ });
40
+ });
@@ -0,0 +1,230 @@
1
+ import type { GeoJSONSourceSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl";
2
+ import type { Rows } from "./chart.js";
3
+
4
+ export type AreaLevel = "prefecture" | "city" | "small_area";
5
+
6
+ /**
7
+ * How rows are put on the map: by area code (the shapes come from Queria's boundary tiles) or by longitude and latitude.
8
+ * The values are column names. `color` colors by a column, `label` names a row on hover.
9
+ */
10
+ export type MapLayer = ({ area: AreaLevel; code: string } | { lng: string; lat: string }) & {
11
+ color?: string;
12
+ label?: string;
13
+ opacity?: number;
14
+ };
15
+
16
+ /** Queria's map styles. They keep their URL when the tiles are rebuilt, and hold the boundary sources. */
17
+ export const MAP_STYLES = {
18
+ light: "https://assets.queria.io/maps/v1/light.json",
19
+ dark: "https://assets.queria.io/maps/v1/dark.json",
20
+ } as const;
21
+
22
+ /** The style's source for each area level. */
23
+ export const boundarySource = (area: AreaLevel): string => `queria-${area}`;
24
+
25
+ /**
26
+ * Put a code into the form of the boundary tiles: restore leading zeros lost in numeric columns, and drop
27
+ * the check digit of a 6-digit local government code.
28
+ */
29
+ export const areaCode = (area: AreaLevel, value: unknown): string | null => {
30
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "bigint") return null;
31
+ const text = String(value).trim();
32
+ if (text === "") return null;
33
+ if (area === "small_area") return text.length === 8 || text.length === 10 ? `0${text}` : text;
34
+ const digits = area === "prefecture" ? 2 : 5;
35
+ return text.length >= digits ? text.slice(0, digits) : text.padStart(digits, "0");
36
+ };
37
+
38
+ const BASE = "#2563eb";
39
+ const LOW = [219, 234, 254];
40
+ const HIGH = [29, 78, 216];
41
+ const CATEGORIES = ["#2563eb", "#ea580c", "#16a34a", "#dc2626", "#9333ea", "#0891b2", "#ca8a04", "#db2777", "#4b5563", "#65a30d"];
42
+
43
+ const hex = (rgb: number[]): string => `#${rgb.map((c) => Math.round(c).toString(16).padStart(2, "0")).join("")}`;
44
+
45
+ /**
46
+ * A color for each row. Numbers run from light to dark between the smallest and largest value;
47
+ * other values get one color each, in order of first appearance.
48
+ */
49
+ export const colorsOf = (rows: Rows, column: string | undefined): string[] => {
50
+ if (column === undefined) return rows.map(() => BASE);
51
+ const values = rows.map((r) => r[column]);
52
+ const nums = values.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
53
+ if (nums.length > 0 && nums.length === values.filter((v) => v !== null && v !== undefined).length) {
54
+ // Spreading a large array into Math.min overflows the stack.
55
+ const min = nums.reduce((a, b) => Math.min(a, b));
56
+ const max = nums.reduce((a, b) => Math.max(a, b));
57
+ return values.map((v) => {
58
+ if (typeof v !== "number" || !Number.isFinite(v)) return "#d4d4d4";
59
+ const t = max === min ? 1 : (v - min) / (max - min);
60
+ return hex(LOW.map((l, i) => l + (HIGH[i] - l) * t));
61
+ });
62
+ }
63
+ const order = [...new Set(values.map(String))];
64
+ return values.map((v) => CATEGORIES[order.indexOf(String(v)) % CATEGORIES.length]);
65
+ };
66
+
67
+ /** Lines shown on hover: the label, then the colored value. */
68
+ const hoverLines = (row: Record<string, unknown>, layer: MapLayer): string[] =>
69
+ [
70
+ layer.label === undefined ? null : String(row[layer.label] ?? ""),
71
+ layer.color === undefined ? null : `${layer.color}: ${typeof row[layer.color] === "number" ? (row[layer.color] as number).toLocaleString() : String(row[layer.color])}`,
72
+ ].filter((l): l is string => !!l);
73
+
74
+ const coordinate = (v: unknown): number =>
75
+ typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : Number.NaN;
76
+
77
+ /** The row's [lng, lat], or null when either is missing or not a number. */
78
+ const positionOf = (row: Record<string, unknown>, layer: { lng: string; lat: string }): [number, number] | null => {
79
+ const [lng, lat] = [coordinate(row[layer.lng]), coordinate(row[layer.lat])];
80
+ return Number.isFinite(lng) && Number.isFinite(lat) ? [lng, lat] : null;
81
+ };
82
+
83
+ /** Points as GeoJSON. Rows without a valid position are left out. */
84
+ export const pointFeatures = (rows: Rows, layer: MapLayer & { lng: string; lat: string }): GeoJSONSourceSpecification["data"] => {
85
+ const colors = colorsOf(rows, layer.color);
86
+ return {
87
+ type: "FeatureCollection",
88
+ features: rows.flatMap((r, i) => {
89
+ const position = positionOf(r, layer);
90
+ if (position === null) return [];
91
+ return [{ type: "Feature" as const, geometry: { type: "Point" as const, coordinates: position }, properties: { color: colors[i], hover: hoverLines(r, layer).join("\n") } }];
92
+ }),
93
+ };
94
+ };
95
+
96
+ /** Color and hover lines by area code. The first row of a code wins. */
97
+ export const areaRows = (rows: Rows, layer: MapLayer & { area: AreaLevel; code: string }): Map<string, { color: string; hover: string }> => {
98
+ const colors = colorsOf(rows, layer.color);
99
+ const out = new Map<string, { color: string; hover: string }>();
100
+ rows.forEach((r, i) => {
101
+ const code = areaCode(layer.area, r[layer.code]);
102
+ if (code !== null && !out.has(code)) out.set(code, { color: colors[i], hover: hoverLines(r, layer).join("\n") });
103
+ });
104
+ return out;
105
+ };
106
+
107
+ type Bbox = [number, number, number, number];
108
+
109
+ const union = (boxes: Bbox[]): Bbox | null =>
110
+ boxes.length === 0
111
+ ? null
112
+ : boxes.reduce((a, b) => [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])]);
113
+
114
+ const bboxIndexes = new Map<string, Promise<Record<string, Bbox>>>();
115
+
116
+ /** The bbox index at the URL, fetched once per page. */
117
+ const bboxIndex = (url: string): Promise<Record<string, Bbox>> => {
118
+ const found = bboxIndexes.get(url);
119
+ if (found) return found;
120
+ const p = fetch(url)
121
+ .then((res) => {
122
+ if (!res.ok) throw new Error(`${url}: ${res.status}`);
123
+ return res.json() as Promise<Record<string, Bbox>>;
124
+ })
125
+ .catch((e: unknown) => {
126
+ bboxIndexes.delete(url);
127
+ throw e;
128
+ });
129
+ bboxIndexes.set(url, p);
130
+ return p;
131
+ };
132
+
133
+ /** The bounds of the points, or of the areas from the style's bbox index (small areas use their city). */
134
+ const boundsOf = async (map: MapLibreMap, layer: MapLayer, rows: Rows, codes: string[]): Promise<Bbox | null> => {
135
+ if ("lng" in layer) {
136
+ return union(rows.flatMap((r): Bbox[] => {
137
+ const p = positionOf(r, layer);
138
+ return p ? [[p[0], p[1], p[0], p[1]]] : [];
139
+ }));
140
+ }
141
+ const url = (map.getStyle().metadata as Record<string, unknown> | undefined)?.["queria:bbox"];
142
+ if (typeof url !== "string") return null;
143
+ const index = await bboxIndex(url);
144
+ return union(codes.flatMap((c): Bbox[] => {
145
+ const box = index[layer.area === "small_area" ? c.slice(0, 5) : c];
146
+ return box ? [box] : [];
147
+ }));
148
+ };
149
+
150
+ export type MapRenderOptions = {
151
+ /** A MapLibre style or its URL. By default Queria's style, which has the boundary tiles. */
152
+ style?: StyleSpecification | string;
153
+ theme?: keyof typeof MAP_STYLES;
154
+ };
155
+
156
+ /**
157
+ * Draw rows on a MapLibre map. maplibre-gl is loaded on first use; include its CSS in your page.
158
+ * The map is fitted to the rows. Sources and licenses are not drawn here; the elements and React components add them.
159
+ */
160
+ export const renderMap = async (
161
+ el: HTMLElement,
162
+ layer: MapLayer,
163
+ rows: Rows,
164
+ { style, theme = "light" }: MapRenderOptions = {},
165
+ ): Promise<{ map: MapLibreMap; finalize: () => void }> => {
166
+ const { default: maplibregl } = await import("maplibre-gl");
167
+ const map = new maplibregl.Map({ container: el, style: style ?? MAP_STYLES[theme], center: [137, 37], zoom: 4 });
168
+ try {
169
+ // An error before the style loads stops the map. Later errors, such as a tile or a sprite that fails to
170
+ // load, leave it drawing, and MapLibre logs them once no listener is left.
171
+ await new Promise<void>((resolve, reject) => {
172
+ const fail = (e: { error: Error }) => reject(e.error);
173
+ map.on("error", fail);
174
+ map.once("style.load", () => map.off("error", fail));
175
+ map.once("load", () => resolve());
176
+ });
177
+ // Areas go under the first label of the base map, so place names stay readable; points go on top.
178
+ const below = map.getStyle().layers.find((l) => l.type === "symbol")?.id;
179
+ const opacity = layer.opacity ?? ("lng" in layer ? 0.9 : 0.6);
180
+ let codes: string[] = [];
181
+ let hover: (f: { properties: Record<string, unknown> }) => string | undefined;
182
+ if ("lng" in layer) {
183
+ map.addSource("queria-rows", { type: "geojson", data: pointFeatures(rows, layer) });
184
+ map.addLayer({
185
+ id: "queria-rows",
186
+ type: "circle",
187
+ source: "queria-rows",
188
+ paint: { "circle-radius": 4, "circle-color": ["get", "color"], "circle-opacity": opacity, "circle-stroke-width": 1, "circle-stroke-color": "#ffffff" },
189
+ });
190
+ hover = (f) => f.properties.hover as string | undefined;
191
+ } else {
192
+ const source = boundarySource(layer.area);
193
+ if (!map.getSource(source)) throw new Error(`The map style has no source "${source}". Use Queria's style to draw areas`);
194
+ const areas = areaRows(rows, layer);
195
+ codes = [...areas.keys()];
196
+ if (codes.length > 0)
197
+ map.addLayer({
198
+ id: "queria-rows",
199
+ type: "fill",
200
+ source,
201
+ "source-layer": layer.area,
202
+ // A match on a literal list is a lookup table; "in" would scan the list for every feature.
203
+ filter: ["match", ["get", "code"], codes, true, false],
204
+ paint: {
205
+ "fill-color": ["match", ["get", "code"], ...[...areas].flatMap(([c, a]) => [c, a.color]), "rgba(0,0,0,0)"] as unknown as string,
206
+ "fill-opacity": opacity,
207
+ "fill-outline-color": "#ffffff",
208
+ },
209
+ }, below);
210
+ hover = (f) => areas.get(String(f.properties.code))?.hover;
211
+ }
212
+ const popup = new maplibregl.Popup({ closeButton: false, closeOnClick: false });
213
+ map.on("mousemove", "queria-rows", (e) => {
214
+ const text = e.features?.[0] ? hover(e.features[0]) : undefined;
215
+ if (!text) return popup.remove();
216
+ // The text comes from the data, so it is set as text, never as HTML.
217
+ const body = document.createElement("div");
218
+ body.style.whiteSpace = "pre-line";
219
+ body.textContent = text;
220
+ popup.setLngLat(e.lngLat).setDOMContent(body).addTo(map);
221
+ });
222
+ map.on("mouseleave", "queria-rows", () => popup.remove());
223
+ const bounds = await boundsOf(map, layer, rows, codes).catch(() => null);
224
+ if (bounds) map.fitBounds(bounds, { padding: 24, maxZoom: 14, duration: 0 });
225
+ } catch (e) {
226
+ map.remove();
227
+ throw e;
228
+ }
229
+ return { map, finalize: () => map.remove() };
230
+ };
@@ -0,0 +1,4 @@
1
+ const entities: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
2
+
3
+ /** Escape text for use in HTML or SVG markup. */
4
+ export const esc = (s: string): string => s.replace(/[&<>"']/g, (c) => entities[c] ?? c);
@@ -0,0 +1,12 @@
1
+ export { attributionHtml } from "./attribution.js";
2
+ export { renderChart, withData, type ChartOptions, type ChartSpec, type Rows } from "./chart.js";
3
+ export { mountMap, type MapHandle } from "./map.js";
4
+ export { poweredByHtml } from "./poweredBy.js";
5
+ export {
6
+ areaCode,
7
+ MAP_STYLES,
8
+ renderMap,
9
+ type AreaLevel,
10
+ type MapLayer,
11
+ type MapRenderOptions,
12
+ } from "./data-map.js";