Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./elements": {
"import": "./dist/elements.js"
}
},
"repository": {
Expand Down
21 changes: 21 additions & 0 deletions core/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,26 @@ export default defineConfig({
externals: ["vega", "vega-lite", "vega-embed"],
},
},
// Self-contained browser bundle (`./elements` export). Loading it registers
// the `<molplot-chart>` custom element, so a docs page needs only a single
// <script type="module">. vega-embed is bundled in — but the chart still
// reaches it through the dynamic `import("vega-embed")` in vega_loader.ts,
// so the bundler code-splits vega into a lazy chunk: registering the element
// is cheap; the ~350 KB runtime downloads only when a chart mounts.
{
format: "esm",
bundle: true,
dts: false,
// rslib externalizes package `dependencies` by default; turn that off here
// so vega/vega-lite/vega-embed are actually bundled into this artifact
// (the dynamic import is still code-split into a lazy chunk).
autoExternal: false,
source: {
entry: { elements: "./src/element_entry.ts" },
},
output: {
target: "web",
},
},
],
});
126 changes: 126 additions & 0 deletions core/src/element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import type { PresetName } from "./preset";
import { RawChart } from "./raw_chart";
import type { VegaLiteSpec } from "./specs";
import type { ThemeMode } from "./types";

/**
* The `<molplot-chart>` custom element — a native Web Component wrapper around
* {@link RawChart} so a Vega-Lite spec can be dropped straight into Markdown (or
* any HTML) and rendered live in the browser. The unified molplot preset is
* injected as the spec `config` (unless the spec carries its own), and
* `<html class="dark">` is tracked, exactly as {@link RawChart} does.
*
* The author-facing content **is a plain Vega-Lite spec** — no bespoke schema.
* It is read from the first child `<script type="application/json">` block, so
* multiline JSON with `<`/`>`/braces survives Markdown/HTML sanitization:
*
* ```html
* <molplot-chart preset="molplot" theme="auto">
* <script type="application/json">
* { "mark": "line",
* "data": { "values": [ {"x":0,"y":1}, {"x":1,"y":2} ] },
* "encoding": { "x": {"field":"x","type":"quantitative"},
* "y": {"field":"y","type":"quantitative"} } }
* </script>
* </molplot-chart>
* ```
*
* Attributes: `preset` (unified preset name, default the molplot preset),
* `theme` (`auto` | `light` | `dark`, default `auto`), and `spec` (inline JSON,
* a one-line alternative to the script block).
*/

/**
* Parse the Vega-Lite spec an author embedded in a `<molplot-chart>`. Reads the
* first child `<script type="application/json">`; falls back to a `spec`
* attribute holding inline JSON. Returns null when no spec is present or the
* JSON is malformed — the element renders an inline error rather than throwing,
* so a typo in one doc block never breaks the page.
*/
export function parseSpec(el: HTMLElement): VegaLiteSpec | null {
const script = el.querySelector('script[type="application/json"]');
const raw = script?.textContent ?? el.getAttribute("spec");
if (!raw?.trim()) return null;
try {
return JSON.parse(raw) as VegaLiteSpec;
} catch {
return null;
}
}

/** Coerce a `theme` attribute to a valid mode, defaulting to `auto`. */
function parseTheme(value: string | null): ThemeMode {
return value === "light" || value === "dark" ? value : "auto";
}

/**
* Register the `<molplot-chart>` custom element. Idempotent and browser-only:
* a no-op when there is no `customElements` registry (SSR/Node) or the tag is
* already defined. The element class is created here, on call, so merely
* importing this module never evaluates `class extends HTMLElement` — the
* library stays import-side-effect-free and safe to load in Node.
*/
export function defineMolplotChart(tag = "molplot-chart"): void {
if (
typeof customElements === "undefined" ||
typeof HTMLElement === "undefined"
)
return;
if (customElements.get(tag)) return;

class MolplotChartElement extends HTMLElement {
static readonly observedAttributes = ["spec", "preset", "theme"];

private chart: RawChart | null = null;
private surface: HTMLElement | null = null;

connectedCallback(): void {
this.mount();
}

disconnectedCallback(): void {
this.teardown();
}

attributeChangedCallback(): void {
// Attributes are also set before the first connect; only react once live.
if (this.isConnected && this.chart) {
this.teardown();
this.mount();
}
}

private mount(): void {
if (this.chart) return; // already mounted (guard double-connect)
const surface = document.createElement("div");
// Render into a dedicated child so the base class's `querySelector("svg")`
// and ResizeObserver have a stable host — never the sibling <script>.
surface.style.display = "block";
this.appendChild(surface);
this.surface = surface;

const spec = parseSpec(this);
if (!spec) {
surface.textContent =
"molplot-chart: missing or invalid Vega-Lite spec";
return;
}
const preset =
(this.getAttribute("preset") as PresetName | null) ?? undefined;
const theme = parseTheme(this.getAttribute("theme"));
this.chart = new RawChart(surface, { spec, preset, theme });
void this.chart.ready().then(() => {
if (this.chart) this.dispatchEvent(new CustomEvent("molplot:ready"));
});
}

private teardown(): void {
this.chart?.dispose();
this.chart = null;
this.surface?.remove();
this.surface = null;
}
}

customElements.define(tag, MolplotChartElement);
}
7 changes: 7 additions & 0 deletions core/src/element_entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Browser bundle entry (see the `elements` lib in rslib.config.ts): loading
// this artifact registers the `<molplot-chart>` custom element as a side effect,
// so a docs page only needs a single <script>. Kept out of the main library
// entry (index.ts) so importing `@molcrafts/molplot` never auto-registers.
import { defineMolplotChart } from "./element";

defineMolplotChart();
5 changes: 5 additions & 0 deletions core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ export type {
BarSeriesConfig,
} from "./bar_chart";
export { BarChart } from "./bar_chart";
// Web Component — register `<molplot-chart>` in the browser. These are plain
// functions that touch the DOM only when called, so importing the library stays
// side-effect-free and SSR-safe (no top-level `class extends HTMLElement`). The
// self-registering browser bundle is the `./elements` subpath.
export { defineMolplotChart, parseSpec } from "./element";
export type {
GanttChartConfig,
GanttClickEvent,
Expand Down
84 changes: 29 additions & 55 deletions core/src/presets/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,13 @@ export const PRESETS = {
"molplot": {
"name": "molplot",
"label": "MolPlot",
"description": "MolCrafts unified scientific charting preset — the single source of truth shared by the Observable Plot + D3 web renderer and the scienceplots/matplotlib Python renderer.",
"description": "MolCrafts unified scientific charting preset, faithful to scienceplots' 'science' base — its standard seven-colour cycle, thin 1.0 pt lines, serif type, and black-on-white axes — so a browser chart and a matplotlib figure share the same scienceplots look.",
"sciencePlotsBase": [
"science",
"no-latex"
],
"typography": {
"family": "Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif",
"family": "Times New Roman, Times, Nimbus Roman, serif",
"familyMono": "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
"familySerif": "Times New Roman, Times, Nimbus Roman, serif",
"size": {
Expand All @@ -71,33 +71,20 @@ export const PRESETS = {
},
"palette": {
"categorical": [
"#1f77b4",
"#ff7f0e",
"#2ca02c",
"#d62728",
"#9467bd",
"#8c564b",
"#e377c2",
"#7f7f7f",
"#bcbd22",
"#17becf",
"#aec7e8",
"#ffbb78",
"#98df8a",
"#ff9896",
"#c5b0d5",
"#c49c94",
"#f7b6d2",
"#c7c7c7",
"#dbdb8d",
"#9edae5"
"#0c5da5",
"#00b945",
"#ff9500",
"#ff2c00",
"#845b97",
"#474747",
"#9e9e9e"
],
"defaultColor": "#60a5fa",
"defaultColor": "#0c5da5",
"sequential": "viridis",
"diverging": "RdBu"
},
"geometry": {
"lineWidth": 2,
"lineWidth": 1,
"markerSize": 6,
"barGap": 0.2,
"figSize": [
Expand All @@ -116,27 +103,27 @@ export const PRESETS = {
"light": {
"background": "transparent",
"figureFace": "#ffffff",
"foreground": "#52525b",
"gridColor": "rgba(120,120,120,0.18)",
"gridColorSolid": "#d4d4d8",
"tickColor": "#a1a1aa",
"foreground": "#000000",
"gridColor": "rgba(0,0,0,0.10)",
"gridColorSolid": "#cccccc",
"tickColor": "#000000",
"highlightRing": "#111827"
},
"dark": {
"background": "transparent",
"figureFace": "#111113",
"foreground": "#d4d4d8",
"gridColor": "rgba(220,220,220,0.12)",
"gridColorSolid": "#3f3f46",
"tickColor": "#71717a",
"foreground": "#e5e5e5",
"gridColor": "rgba(255,255,255,0.10)",
"gridColorSolid": "#444444",
"tickColor": "#e5e5e5",
"highlightRing": "#f9fafb"
}
}
} as const,
"molplot-paper": {
"name": "molplot-paper",
"label": "MolPlot Paper",
"description": "Publication variant of the MolPlot preset — serif type, tighter figure, high DPI, layered on scienceplots' 'nature' base. Same categorical palette as the default so web and paper figures stay colour-consistent.",
"description": "Publication variant layered on scienceplots' 'science' + 'nature' bases — the same standard seven-colour cycle as the default, thin 1.0 pt lines and serif type, but a tighter single-column figure and higher DPI. Web and paper figures stay colour-consistent.",
"sciencePlotsBase": [
"science",
"nature",
Expand All @@ -156,34 +143,21 @@ export const PRESETS = {
},
"palette": {
"categorical": [
"#1f77b4",
"#ff7f0e",
"#2ca02c",
"#d62728",
"#9467bd",
"#8c564b",
"#e377c2",
"#7f7f7f",
"#bcbd22",
"#17becf",
"#aec7e8",
"#ffbb78",
"#98df8a",
"#ff9896",
"#c5b0d5",
"#c49c94",
"#f7b6d2",
"#c7c7c7",
"#dbdb8d",
"#9edae5"
"#0c5da5",
"#00b945",
"#ff9500",
"#ff2c00",
"#845b97",
"#474747",
"#9e9e9e"
],
"defaultColor": "#1f77b4",
"defaultColor": "#0c5da5",
"sequential": "viridis",
"diverging": "RdBu"
},
"geometry": {
"lineWidth": 1,
"markerSize": 4,
"markerSize": 3,
"barGap": 0.2,
"figSize": [
3.3,
Expand Down
12 changes: 11 additions & 1 deletion core/src/raw_chart.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { VegaChart } from "./chart_base";
import type { PresetName } from "./preset";
import type { VegaLiteSpec } from "./specs";
import { type ChartTheme, vegaConfig } from "./theme";
import type { ThemeMode } from "./types";

/**
* Escape hatch: render an arbitrary Vega-Lite spec verbatim. Now that the
Expand All @@ -17,13 +19,21 @@ import { type ChartTheme, vegaConfig } from "./theme";
export interface RawChartConfig {
/** A Vega-Lite top-level spec. Inline `data.values` is rendered as-is. */
spec: VegaLiteSpec;
/**
* Which unified preset to inject as the spec `config` when the spec carries
* none. Defaults to the default preset — the same tokens the Python package
* applies. A spec with its own `config` is always respected verbatim.
*/
preset?: PresetName;
/** Theme mode. `auto` (default) tracks `<html class="dark">`. */
theme?: ThemeMode;
}

export class RawChart extends VegaChart {
private spec: VegaLiteSpec;

constructor(container: HTMLElement, config: RawChartConfig) {
super(container, "auto", undefined);
super(container, config.theme ?? "auto", config.preset);
this.spec = config.spec;
}

Expand Down
7 changes: 4 additions & 3 deletions core/src/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { getPreset } from "./preset";
import type { ThemeMode } from "./types";

/**
* 20-entry categorical palette (d3 category20). Re-exported for API
* compatibility with the former plotly build; the authoritative copy now
* lives in `presets/molplot.json` and flows through {@link getPreset}.
* Categorical palette — scienceplots' standard seven-colour "science" cycle.
* Re-exported for API compatibility with the former plotly build; the
* authoritative copy now lives in `presets/molplot.json` and flows through
* {@link getPreset}.
*/
export const CHART_PALETTE: readonly string[] = getPreset().palette.categorical;

Expand Down
Loading
Loading