@figurestead/web 0.9.0-alpha.1

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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -0
  3. package/THIRD_PARTY_NOTICES.md +39 -0
  4. package/TRADEMARKS.md +13 -0
  5. package/package.json +31 -0
  6. package/src/accessibility.js +33 -0
  7. package/src/appearance.js +25 -0
  8. package/src/application-profiles.js +76 -0
  9. package/src/atmosphere.js +71 -0
  10. package/src/canvas-scene.js +323 -0
  11. package/src/clock.js +27 -0
  12. package/src/color-space.js +114 -0
  13. package/src/composition.js +201 -0
  14. package/src/core-renderers.js +33 -0
  15. package/src/create-matrix-plot.js +97 -0
  16. package/src/evidence-coverage.js +80 -0
  17. package/src/export-bundle.js +83 -0
  18. package/src/extensions/temporal/coverage.js +96 -0
  19. package/src/extensions/temporal/index.js +43 -0
  20. package/src/extensions/temporal/observations.js +87 -0
  21. package/src/extensions/temporal/shared.js +172 -0
  22. package/src/figure-layout.js +70 -0
  23. package/src/figure.js +42 -0
  24. package/src/index.js +30 -0
  25. package/src/layout.js +41 -0
  26. package/src/marks.js +218 -0
  27. package/src/motion-plan.js +66 -0
  28. package/src/motion-recipes.js +39 -0
  29. package/src/paper-profile.js +78 -0
  30. package/src/physical-export.js +34 -0
  31. package/src/presentation.js +127 -0
  32. package/src/primitives.js +24 -0
  33. package/src/random.js +26 -0
  34. package/src/registry.js +29 -0
  35. package/src/render-layers.js +35 -0
  36. package/src/renderer-test-kit.js +29 -0
  37. package/src/renderers/line.js +133 -0
  38. package/src/renderers/scatter.js +24 -0
  39. package/src/renderers/shared.js +21 -0
  40. package/src/renderers/strip-summary.js +40 -0
  41. package/src/resolved-scene.js +249 -0
  42. package/src/scales.js +68 -0
  43. package/src/schema.js +356 -0
  44. package/src/series-style.js +59 -0
  45. package/src/svg-export.js +232 -0
  46. package/src/terminal-scene.js +215 -0
  47. package/src/theme-catalog.js +34 -0
  48. package/src/theme-pack.js +268 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Figurestead contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # @figurestead/web experimental public alpha
2
+
3
+ The package root provides Figurestead's accepted core rendering and custom
4
+ renderer-registry APIs. `EXTENSION_RENDERERS` and `FIGURESTEAD_REGISTRY` are
5
+ not root exports. The complete temporal extension is available from
6
+ `@figurestead/web/extensions/temporal`.
7
+
8
+ Version 0.9.0-alpha.1. Source:
9
+ <https://github.com/CharlesMish/figurestead/tree/main/web>.
@@ -0,0 +1,39 @@
1
+ # Third-party factual notice inventory
2
+
3
+ Status: `EXPERIMENTAL PUBLIC-ALPHA FACTUAL NOTICE`
4
+
5
+ This inventory records the dependency evidence used for the Figurestead
6
+ experimental alpha; it makes no legal conclusion. Runtime dependencies are not
7
+ vendored into the wheel, sdist, or npm tarball. The canonical Figurestead MIT
8
+ text is supplied separately as `LICENSE`.
9
+
10
+ ## Python dependency observations
11
+
12
+ An external Python 3.14.6 evidence environment resolved the current declared
13
+ constraints on 2026-08-05 to Matplotlib 3.11.1, NumPy 2.5.1, Pillow 12.3.0,
14
+ PyYAML 6.0.3, and setuptools 82.0.1, plus contourpy 1.3.3, cycler 0.12.1,
15
+ fonttools 4.63.0, kiwisolver 1.5.0, packaging 26.3, pyparsing 3.3.2,
16
+ python-dateutil 2.9.0.post0, and six 1.17.0. These versions are an evidence
17
+ snapshot, not an approved runtime lock.
18
+
19
+ The static-site PNGs are first-party deterministic synthetic Figurestead render
20
+ outputs produced through Matplotlib. No font file is embedded as a separate
21
+ site member. Matplotlib's evidence distribution includes its own license plus
22
+ DejaVu and STIX font notices; rendered glyph provenance remains a review
23
+ consideration. Site CSS names
24
+ only system font families and carries no downloaded font bytes or remote URL.
25
+
26
+ ## Browser tooling observations
27
+
28
+ The alpha verification environment used Playwright 1.61.1 and
29
+ playwright-core 1.61.1 as
30
+ Apache-2.0 development/test tooling and optional fsevents 2.3.2 as MIT. These
31
+ packages and downloaded browsers are not intended npm-tarball members or
32
+ top-level archive members. They remain test-tool provenance only.
33
+
34
+ ## Placement and maintenance
35
+
36
+ Each Figurestead wheel, sdist, npm package, static-site bundle, and composition
37
+ archive carries this file and `TRADEMARKS.md` beside its canonical `LICENSE`.
38
+ Any newly resolved dependency version, copied source, font, image, or other
39
+ byte requires a corresponding inventory update and review.
package/TRADEMARKS.md ADDED
@@ -0,0 +1,13 @@
1
+ # Trademarks and names
2
+
3
+ Figurestead is an independent project.
4
+
5
+ Names of third-party products, services, organizations, and trademarks remain
6
+ the property of their respective owners. Their appearance in factual dependency
7
+ or compatibility notices does not imply affiliation, sponsorship, or
8
+ endorsement.
9
+
10
+ The experimental public-alpha distribution is intentionally limited to six
11
+ curated Figurestead themes.
12
+ Legacy or franchise-derived internal themes are not part of the public
13
+ distribution.
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@figurestead/web",
3
+ "version": "0.9.0-alpha.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "description": "A framework-free scientific figure system for deterministic, inspectable Python and browser output.",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/CharlesMish/figurestead.git",
11
+ "directory": "web"
12
+ },
13
+ "homepage": "https://github.com/CharlesMish/figurestead#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/CharlesMish/figurestead/issues"
16
+ },
17
+ "engines": {
18
+ "node": ">=24 <25"
19
+ },
20
+ "exports": {
21
+ ".": "./src/index.js",
22
+ "./extensions/temporal": "./src/extensions/temporal/index.js"
23
+ },
24
+ "files": [
25
+ "src",
26
+ "LICENSE",
27
+ "README.md",
28
+ "THIRD_PARTY_NOTICES.md",
29
+ "TRADEMARKS.md"
30
+ ]
31
+ }
@@ -0,0 +1,33 @@
1
+ const el = (name, text) => { const node = document.createElement(name); if (text != null) node.textContent = text; return node; };
2
+
3
+ function appendTable(root, panel, description) {
4
+ if (!description?.headers?.length || !Array.isArray(description.rows)) return;
5
+ const details = el("details"), summary = el("summary", `View ${panel.spec.title || panel.renderer} data`), table = el("table");
6
+ table.append(el("caption", `${panel.spec.title || panel.renderer} data`));
7
+ const head = el("thead"), body = el("tbody"), headingRow = el("tr");
8
+ description.headers.forEach((value) => headingRow.append(el("th", value))); head.append(headingRow);
9
+ description.rows.forEach((row) => { const tr = el("tr"); row.forEach((value, index) => { const cell = el(index === 0 ? "th" : "td", String(value)); if (index === 0) cell.scope = "row"; tr.append(cell); }); body.append(tr); });
10
+ table.append(head, body); details.append(summary, table); root.append(details);
11
+ }
12
+
13
+ export function createAccessibilityCompanion(canvas, contract, registry, { visible = false, table = true } = {}) {
14
+ const id = `figurestead-${Math.random().toString(36).slice(2)}`, root = el("section");
15
+ root.className = visible ? "figurestead-accessibility" : "figurestead-accessibility figurestead-sr-only";
16
+ if (!visible) Object.assign(root.style, { position: "absolute", width: "1px", height: "1px", padding: "0", margin: "-1px", overflow: "hidden", clip: "rect(0, 0, 0, 0)", whiteSpace: "nowrap", border: "0" });
17
+ const title = el("h2", contract.spec.title); title.id = `${id}-title`; root.append(title);
18
+ if (contract.spec.subtitle) root.append(el("p", contract.spec.subtitle));
19
+ const description = el("p", contract.spec.description || `${contract.panels.length}-panel scientific figure.`); description.id = `${id}-description`; root.append(description);
20
+ contract.panels.forEach((panel, index) => {
21
+ const section = el("section"), heading = el("h3", panel.spec.title || `${panel.renderer.replaceAll("_", " ")} panel`), definition = registry.get(panel.renderer), described = definition.describe({ ...contract, renderer: panel.renderer, spec: { ...contract.spec, ...panel.spec }, data: panel.data });
22
+ heading.id = `${id}-panel-${index + 1}`; section.setAttribute("aria-labelledby", heading.id); section.append(heading);
23
+ if (panel.spec.description || described.summary) section.append(el("p", panel.spec.description || described.summary));
24
+ panel.annotations.filter((item) => item?.type === "focus" && typeof item.label === "string" && item.label.trim()).forEach((item) => {
25
+ section.append(el("p", `Focus annotation: ${item.label} at x ${item.x}, y ${item.y}.`));
26
+ });
27
+ const dl = el("dl");
28
+ [["Horizontal axis", panel.spec.xLabel || panel.xScale.label || "Unlabelled"], ["Vertical axis", panel.spec.yLabel || panel.yScale.label || "Unlabelled"], ["Renderer", panel.renderer.replaceAll("_", " ")]].forEach(([term, value]) => dl.append(el("dt", term), el("dd", value)));
29
+ section.append(dl); if (table) appendTable(section, panel, described); root.append(section);
30
+ });
31
+ canvas.setAttribute("role", "img"); canvas.setAttribute("aria-labelledby", `${title.id} ${description.id}`); canvas.after(root);
32
+ return { root, destroy() { root.remove(); canvas.removeAttribute("role"); canvas.removeAttribute("aria-labelledby"); } };
33
+ }
@@ -0,0 +1,25 @@
1
+ import { applyApplicationProfile, resolveApplicationProfile } from "./application-profiles.js";
2
+ import { cloneValue } from "./schema.js";
3
+ import { applyTheme, resolvePalette, resolveTheme, themeForProfile } from "./theme-pack.js";
4
+
5
+ export const APPEARANCE_VERSION = "figurestead.appearance/1";
6
+
7
+ /**
8
+ * Resolve profile and palette/theme choices as one deliberate application step.
9
+ * Scientific data, encodings, domains, notes, and ordering are never rewritten.
10
+ */
11
+ export function composeAppearance(input, options = {}) {
12
+ const profile = resolveApplicationProfile(options.profile ?? input.view?.profile ?? "atlas");
13
+ let result = cloneValue(input);
14
+ if (options.palettePack) {
15
+ if (!options.paletteKey) throw new TypeError("composeAppearance requires paletteKey with palettePack");
16
+ result = applyTheme(result, resolvePalette(options.palettePack, options.paletteKey, { mode: options.mode ?? profile.key }).theme);
17
+ } else if (options.themePack) {
18
+ if (!options.themeKey) throw new TypeError("composeAppearance requires themeKey with themePack");
19
+ result = applyTheme(result, resolveTheme(options.themePack, options.themeKey));
20
+ } else if (options.theme) {
21
+ result = applyTheme(result, options.theme);
22
+ }
23
+ result.theme = themeForProfile(result.theme, profile);
24
+ return applyApplicationProfile(result, profile, options);
25
+ }
@@ -0,0 +1,76 @@
1
+ import { cloneValue, FiguresteadConfigError } from "./schema.js";
2
+
3
+ export const APPLICATION_PROFILE_VERSION = "figurestead.application-profile/1";
4
+
5
+ const BUILT_INS = {
6
+ paper: {
7
+ key: "paper", name: "Paper", surface: "neutral", density: "compact",
8
+ typography: "publication", legend: "outside", ambient: "none", motion: "none",
9
+ lineWidth: 1.55, markerScale: 0.92, panelSurface: false, frame: false,
10
+ },
11
+ atlas: {
12
+ key: "atlas", name: "Atlas", surface: "themed", density: "balanced",
13
+ typography: "scientific", legend: "auto", ambient: "none", motion: "semantic",
14
+ lineWidth: 2.15, markerScale: 1.12, panelSurface: true, frame: true,
15
+ },
16
+ talk: {
17
+ key: "talk", name: "Talk", surface: "themed", density: "open",
18
+ typography: "display", legend: "auto", ambient: "none", motion: "semantic",
19
+ lineWidth: 2.75, markerScale: 1.34, panelSurface: true, frame: true,
20
+ },
21
+ };
22
+
23
+ export const APPLICATION_PROFILES = Object.freeze(Object.fromEntries(
24
+ Object.entries(BUILT_INS).map(([key, value]) => [key, Object.freeze(value)]),
25
+ ));
26
+
27
+ export function resolveApplicationProfile(value = "atlas") {
28
+ const source = typeof value === "string" ? APPLICATION_PROFILES[value] : value;
29
+ if (!source || typeof source !== "object" || Array.isArray(source)) {
30
+ throw new FiguresteadConfigError(`unknown application profile; choose ${Object.keys(APPLICATION_PROFILES).join(", ")}`, "config.view.profile");
31
+ }
32
+ const key = source.key;
33
+ if (!APPLICATION_PROFILES[key] && typeof value === "string") {
34
+ throw new FiguresteadConfigError(`unknown application profile; choose ${Object.keys(APPLICATION_PROFILES).join(", ")}`, "config.view.profile");
35
+ }
36
+ for (const token of ["key", "name", "surface", "density", "typography", "legend", "ambient", "motion"]) {
37
+ if (typeof source[token] !== "string" || !source[token]) throw new FiguresteadConfigError("must be a non-empty string", `config.view.profile.${token}`);
38
+ }
39
+ for (const token of ["lineWidth", "markerScale"]) {
40
+ if (!Number.isFinite(source[token]) || source[token] <= 0) throw new FiguresteadConfigError("must be a positive number", `config.view.profile.${token}`);
41
+ }
42
+ for (const token of ["panelSurface", "frame"]) {
43
+ if (typeof source[token] !== "boolean") throw new FiguresteadConfigError("must be boolean", `config.view.profile.${token}`);
44
+ }
45
+ return Object.freeze(cloneValue(source));
46
+ }
47
+
48
+ export function applyApplicationProfile(input, profile = "atlas", options = {}) {
49
+ const result = cloneValue(input), resolved = resolveApplicationProfile(profile);
50
+ const previous = resolveApplicationProfile(result.view?.profile ?? "atlas");
51
+ const previousPresentation = {
52
+ panelSurface: previous.panelSurface, frame: previous.frame,
53
+ legend: previous.legend === "outside" ? "outside-right" : previous.legend === "auto" ? "auto" : "bottom-right",
54
+ lineWidth: previous.lineWidth, markerScale: previous.markerScale,
55
+ };
56
+ const motion = options.motion ?? resolved.motion;
57
+ const ambient = options.ambient ?? resolved.ambient;
58
+ result.view = {
59
+ profile: resolved.key,
60
+ motion,
61
+ ambient,
62
+ strategy: options.strategy ?? (motion === "semantic" ? "auto" : "none"),
63
+ };
64
+ result.panels = result.panels.map((panel) => {
65
+ const overrides = Object.fromEntries(Object.entries(panel.presentation ?? {}).filter(([key, value]) => previousPresentation[key] !== value));
66
+ return { ...panel, presentation: {
67
+ panelSurface: resolved.panelSurface,
68
+ frame: resolved.frame,
69
+ legend: options.legend ?? (resolved.legend === "outside" ? "outside-right" : resolved.legend === "auto" ? "auto" : "bottom-right"),
70
+ lineWidth: resolved.lineWidth,
71
+ markerScale: resolved.markerScale,
72
+ ...overrides,
73
+ } };
74
+ });
75
+ return result;
76
+ }
@@ -0,0 +1,71 @@
1
+ import { deriveSeed, mulberry32 } from "./random.js";
2
+ import { smooth } from "./marks.js";
3
+ import { windowProgress } from "./schema.js";
4
+
5
+ const GLYPHS = [..."0123456789ABCDEFXYZ<>[]{}+=αβ∑∫≈"];
6
+
7
+ export function prepareAtmosphere(motion) {
8
+ const random = mulberry32(deriveSeed(motion.seed, "atmosphere"));
9
+ return Array.from({ length: motion.rainStreams }, () => ({
10
+ x: 0.02 + random() * 0.96,
11
+ phase: random() * 1.45,
12
+ speed: 0.48 + random() * 0.47,
13
+ spacing: 0.045 + random() * 0.033,
14
+ base: 0.065 + random() * 0.065,
15
+ glyphs: Array.from({ length: motion.rainGlyphs }, () => GLYPHS[Math.floor(random() * GLYPHS.length)]),
16
+ }));
17
+ }
18
+
19
+ export function atmosphereState(progress, timeline, reducedMotion = false) {
20
+ if (reducedMotion || progress >= 1) return { rainEnvelope: 0, lightEnvelope: 0 };
21
+ const rise = smooth(windowProgress(progress, timeline.rainIn));
22
+ const fall = 1 - smooth(windowProgress(progress, timeline.rainOut));
23
+ const rainEnvelope = rise * fall;
24
+ const mark = Math.sin(Math.PI * windowProgress(progress, timeline.marksEnter)) ** 2;
25
+ return { rainEnvelope, lightEnvelope: 0.72 * rainEnvelope + 0.28 * mark };
26
+ }
27
+
28
+ export function drawAtmosphere(context, { config, layout, streams, progress, reducedMotion }) {
29
+ const state = atmosphereState(progress, config.timeline, reducedMotion);
30
+ if (state.rainEnvelope < 0.001 && state.lightEnvelope < 0.001) return;
31
+ const { plot } = layout;
32
+ context.save();
33
+ context.beginPath();
34
+ context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top);
35
+ context.clip();
36
+
37
+ if (state.lightEnvelope > 0.001) {
38
+ streams.forEach((stream) => {
39
+ const x = plot.left + stream.x * (plot.right - plot.left);
40
+ const radius = Math.max(24, 55 * layout.scale);
41
+ const gradient = context.createLinearGradient(x - radius, 0, x + radius, 0);
42
+ gradient.addColorStop(0, "transparent");
43
+ gradient.addColorStop(0.5, config.theme.primary);
44
+ gradient.addColorStop(1, "transparent");
45
+ context.globalAlpha = config.motion.lightingPeak * state.lightEnvelope * 0.22;
46
+ context.fillStyle = gradient;
47
+ context.fillRect(x - radius, plot.top, radius * 2, plot.bottom - plot.top);
48
+ });
49
+ }
50
+
51
+ context.font = `${Math.max(8, 10 * layout.scale)}px ui-monospace, SFMono-Regular, Menlo, monospace`;
52
+ context.textAlign = "center";
53
+ context.textBaseline = "middle";
54
+ streams.forEach((stream) => {
55
+ const head = 1.18 - ((stream.phase + progress * stream.speed * 2.25) % 1.42);
56
+ stream.glyphs.forEach((glyph, index) => {
57
+ const normalizedY = head + index * stream.spacing;
58
+ if (normalizedY < 0 || normalizedY > 1) return;
59
+ const fade = Math.max(0.04, 1 - index / stream.glyphs.length);
60
+ context.globalAlpha = stream.base * fade * state.rainEnvelope;
61
+ context.fillStyle = index === 0 ? config.theme.summaryCore : config.theme.primary;
62
+ context.fillText(
63
+ glyph,
64
+ plot.left + stream.x * (plot.right - plot.left),
65
+ plot.bottom - normalizedY * (plot.bottom - plot.top),
66
+ );
67
+ });
68
+ });
69
+ context.restore();
70
+ context.globalAlpha = 1;
71
+ }