diff --git a/core/package.json b/core/package.json index 4431f80..fc9e2f2 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@molcrafts/molplot", - "version": "0.1.4", + "version": "0.1.5", "type": "module", "exports": { ".": { diff --git a/core/src/annotations.ts b/core/src/annotations.ts deleted file mode 100644 index f1def97..0000000 --- a/core/src/annotations.ts +++ /dev/null @@ -1,385 +0,0 @@ -/** - * Chart annotations as **Vega-Lite layers** (not a separate overlay). - * - * Vega-Lite has no `annotate` / `arrowstyle='|-|'` and no official plugin - * system for annotations (research prototypes only). We expand a small - * molplot extension (`annotations: [...]`) into ordinary `rule` / `text` - * layers so they live in the VL scenegraph and **pan/zoom with the chart**. - * - * Caps are axis-aligned ticks in data space (VL-native). Perfect screen-space - * right angles on log axes would need post-layout pixel math and an overlay - * that is hard to keep in sync with `bind: scales`. - */ - -import type { VegaLiteSpec } from "./specs"; - -export type ScaleBarAnnotation = { - kind: "scaleBar"; - /** Start (data coords). For `along`, a point on the curve. */ - x: number; - y: number; - x2?: number; - y2?: number; - length?: number; - label?: string; - color?: string; - strokeWidth?: number; - /** - * `along` — chord (x,y)→(x2,y2), optionally shifted in data space. - * `horizontal` / `vertical` — axis-aligned size bar. - */ - orientation?: "horizontal" | "vertical" | "along"; - /** - * Multiplicative y-shift for `along` bars (default 1.4) so the bar sits - * above the curve on log–y plots without leaving the VL scenegraph. - */ - yOffset?: number; - /** End-cap half-length as a fraction of local y (log-friendly). Default 0.25. */ - capFrac?: number; - fontSize?: number; -}; - -export type ArrowAnnotation = { - kind: "arrow"; - x: number; - y: number; - x2: number; - y2: number; - label?: string; - color?: string; - strokeWidth?: number; - fontSize?: number; -}; - -export type Annotation = ScaleBarAnnotation | ArrowAnnotation; - -export function scaleBar( - partial: Omit, -): ScaleBarAnnotation { - const hasEnds = partial.x2 != null && partial.y2 != null; - return { - color: "#18432b", - strokeWidth: 2, - orientation: hasEnds ? "along" : "horizontal", - yOffset: 1.4, - capFrac: 0.25, - fontSize: 14, - ...partial, - kind: "scaleBar", - }; -} - -export function arrow(partial: Omit): ArrowAnnotation { - return { - color: "#18432b", - strokeWidth: 1.6, - fontSize: 14, - ...partial, - kind: "arrow", - }; -} - -export function takeAnnotations(spec: Record): { - spec: Record; - annotations: Annotation[]; -} { - const raw = spec.annotations; - if (!Array.isArray(raw) || raw.length === 0) { - if ("annotations" in spec) { - const { annotations: _d, ...rest } = spec; - return { spec: rest, annotations: [] }; - } - return { spec, annotations: [] }; - } - const annotations = raw.filter( - (a): a is Annotation => - !!a && - typeof a === "object" && - ((a as Annotation).kind === "scaleBar" || - (a as Annotation).kind === "arrow"), - ); - const { annotations: _d, ...rest } = spec; - return { spec: rest, annotations }; -} - -function clean(layer: Record): VegaLiteSpec { - for (const k of Object.keys(layer)) { - if (layer[k] === undefined) delete layer[k]; - } - return layer as VegaLiteSpec; -} - -/** - * Expand annotations into VL layers (rule / text). These join the chart - * scenegraph so pan/zoom applies automatically. - */ -export function annotationLayers(annotations: Annotation[]): VegaLiteSpec[] { - const out: VegaLiteSpec[] = []; - for (const a of annotations) { - if (a.kind === "scaleBar") out.push(...scaleBarLayers(a)); - else if (a.kind === "arrow") out.push(...arrowLayers(a)); - } - return out; -} - -/** Merge annotation layers into a unit or layered VL spec. */ -export function withAnnotations( - spec: VegaLiteSpec, - annotations: Annotation[] | undefined | null, -): VegaLiteSpec { - if (!annotations?.length) return spec; - const extra = annotationLayers(annotations); - if (!extra.length) return spec; - - if (Array.isArray(spec.layer)) { - return { ...spec, layer: [...(spec.layer as VegaLiteSpec[]), ...extra] }; - } - if (spec.mark !== undefined || spec.encoding !== undefined) { - const { mark, encoding, data, transform, params, ...rest } = spec as Record< - string, - unknown - >; - const base: VegaLiteSpec = {}; - if (data !== undefined) base.data = data; - if (mark !== undefined) base.mark = mark; - if (encoding !== undefined) base.encoding = encoding; - if (transform !== undefined) base.transform = transform; - if (params !== undefined) base.params = params; - return { ...rest, layer: [base, ...extra] } as VegaLiteSpec; - } - return { ...spec, layer: extra }; -} - -function scaleBarLayers(a: ScaleBarAnnotation): VegaLiteSpec[] { - const color = a.color ?? "#18432b"; - const sw = a.strokeWidth ?? 2; - const fontSize = a.fontSize ?? 14; - const orient = - a.orientation ?? (a.x2 != null && a.y2 != null ? "along" : "horizontal"); - const layers: VegaLiteSpec[] = []; - const q = { type: "quantitative" as const }; - - if (orient === "along" && a.x2 != null && a.y2 != null) { - // Lift the whole bar above the curve in data y (works on linear & log-y). - const yMul = a.yOffset ?? 1.4; - const x0 = a.x; - const x1 = a.x2; - const y0 = a.y * yMul; - const y1 = a.y2 * yMul; - const capFrac = a.capFrac ?? 0.25; - // Vertical caps in data space (VL-native; pan/zoom with scales). - const c0lo = y0 / (1 + capFrac); - const c0hi = y0 * (1 + capFrac); - const c1lo = y1 / (1 + capFrac); - const c1hi = y1 * (1 + capFrac); - const midX = Math.sqrt(x0 * x1); // geometric mid for log-x - const midY = Math.sqrt(y0 * y1) * (1 + capFrac * 0.5); - - layers.push( - clean({ - data: { values: [{ x: x0, y: y0, x2: x1, y2: y1 }] }, - mark: { type: "rule", strokeWidth: sw, color, strokeCap: "butt" }, - encoding: { - x: { field: "x", ...q }, - y: { field: "y", ...q }, - x2: { field: "x2" }, - y2: { field: "y2" }, - }, - }), - clean({ - data: { - values: [ - { x: x0, y: c0lo, y2: c0hi }, - { x: x1, y: c1lo, y2: c1hi }, - ], - }, - mark: { type: "rule", strokeWidth: sw, color }, - encoding: { - x: { field: "x", ...q }, - y: { field: "y", ...q }, - y2: { field: "y2" }, - }, - }), - ); - if (a.label) { - layers.push( - clean({ - data: { values: [{ x: midX, y: midY, label: a.label }] }, - mark: { - type: "text", - fontSize, - font: "Times New Roman, Times, STIX Two Text, serif", - fontStyle: "normal", - color, - align: "center", - baseline: "bottom", - }, - encoding: { - x: { field: "x", ...q }, - y: { field: "y", ...q }, - text: { field: "label", type: "nominal" }, - }, - }), - ); - } - return layers; - } - - if (orient === "vertical") { - const y1 = a.y2 ?? a.y + (a.length ?? 0); - const mid = (a.y + y1) / 2; - const tick = Math.abs(y1 - a.y) * 0.08 || a.x * 0.05; - layers.push( - clean({ - data: { values: [{ x: a.x, y: a.y, y2: y1 }] }, - mark: { type: "rule", strokeWidth: sw, color }, - encoding: { - x: { field: "x", ...q }, - y: { field: "y", ...q }, - y2: { field: "y2" }, - }, - }), - clean({ - data: { - values: [ - { x: a.x - tick, x2: a.x + tick, y: a.y }, - { x: a.x - tick, x2: a.x + tick, y: y1 }, - ], - }, - mark: { type: "rule", strokeWidth: sw, color }, - encoding: { - x: { field: "x", ...q }, - x2: { field: "x2" }, - y: { field: "y", ...q }, - }, - }), - ); - if (a.label) { - layers.push( - clean({ - data: { values: [{ x: a.x, y: mid, label: a.label }] }, - mark: { - type: "text", - dx: 10, - fontSize, - font: "Times New Roman, Times, serif", - color, - align: "left", - baseline: "middle", - }, - encoding: { - x: { field: "x", ...q }, - y: { field: "y", ...q }, - text: { field: "label", type: "nominal" }, - }, - }), - ); - } - return layers; - } - - // horizontal - const x1 = a.x2 ?? a.x + (a.length ?? 0); - const mid = (a.x + x1) / 2; - const capFrac = a.capFrac ?? 0.25; - const yLo = a.y / (1 + capFrac); - const yHi = a.y * (1 + capFrac); - layers.push( - clean({ - data: { values: [{ x: a.x, x2: x1, y: a.y }] }, - mark: { type: "rule", strokeWidth: sw, color, strokeCap: "butt" }, - encoding: { - x: { field: "x", ...q }, - x2: { field: "x2" }, - y: { field: "y", ...q }, - }, - }), - clean({ - data: { - values: [ - { x: a.x, y: yLo, y2: yHi }, - { x: x1, y: yLo, y2: yHi }, - ], - }, - mark: { type: "rule", strokeWidth: sw, color }, - encoding: { - x: { field: "x", ...q }, - y: { field: "y", ...q }, - y2: { field: "y2" }, - }, - }), - ); - if (a.label) { - layers.push( - clean({ - data: { values: [{ x: mid, y: yHi, label: a.label }] }, - mark: { - type: "text", - dy: -4, - fontSize, - font: "Times New Roman, Times, serif", - color, - align: "center", - baseline: "bottom", - }, - encoding: { - x: { field: "x", ...q }, - y: { field: "y", ...q }, - text: { field: "label", type: "nominal" }, - }, - }), - ); - } - return layers; -} - -function arrowLayers(a: ArrowAnnotation): VegaLiteSpec[] { - const color = a.color ?? "#18432b"; - const sw = a.strokeWidth ?? 1.6; - const fontSize = a.fontSize ?? 14; - const q = { type: "quantitative" as const }; - const layers: VegaLiteSpec[] = [ - clean({ - data: { - values: [{ x: a.x, y: a.y, x2: a.x2, y2: a.y2 }], - }, - mark: { type: "rule", strokeWidth: sw, color, strokeCap: "round" }, - encoding: { - x: { field: "x", ...q }, - y: { field: "y", ...q }, - x2: { field: "x2" }, - y2: { field: "y2" }, - }, - }), - ]; - if (a.label) { - layers.push( - clean({ - data: { - values: [ - { - x: (a.x + a.x2) / 2, - y: (a.y + a.y2) / 2, - label: a.label, - }, - ], - }, - mark: { - type: "text", - dy: -8, - fontSize, - font: "Times New Roman, Times, serif", - color, - align: "center", - baseline: "bottom", - }, - encoding: { - x: { field: "x", ...q }, - y: { field: "y", ...q }, - text: { field: "label", type: "nominal" }, - }, - }), - ); - } - return layers; -} diff --git a/core/src/chart_base.ts b/core/src/chart_base.ts index d53b8b3..2167b63 100644 --- a/core/src/chart_base.ts +++ b/core/src/chart_base.ts @@ -189,7 +189,8 @@ export abstract class VegaChart { private async renderImpl(): Promise { if (!this.embed || this.disposed) return; - const theme = resolveTheme(this.themeMode, this.presetName); + // Host element → page body type/color so docs charts match .md-typeset. + const theme = resolveTheme(this.themeMode, this.presetName, this.container); const { width, height } = this.dims(); this.lastW = width; this.lastH = height; @@ -220,10 +221,7 @@ export abstract class VegaChart { this.afterRender(result); } - /** - * Hook after a successful embed. Default no-op. - * Annotations are VL layers (see `withAnnotations`), not a post-render overlay. - */ + /** Hook after a successful embed. Default no-op. */ protected afterRender(_result: EmbedResult): void {} /** diff --git a/core/src/index.ts b/core/src/index.ts index ee75dc2..4381d7a 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -1,18 +1,5 @@ // Chart classes (imperative, framework-agnostic) --------------------------- -// Annotations (scale bar, arrows) ----------------------------------------- -export type { - Annotation, - ArrowAnnotation, - ScaleBarAnnotation, -} from "./annotations"; -export { - annotationLayers, - arrow, - scaleBar, - takeAnnotations, - withAnnotations, -} from "./annotations"; export type { BarChartConfig, BarClickEvent, @@ -56,9 +43,13 @@ export { export { CHART_DEFAULT_COLOR, CHART_PALETTE, + CHART_SERIF_STACK, type ChartTheme, + fontScaleForHost, fontScaleForWidth, + type HostStyle, MOLPLOT_DESIGN_WIDTH, + readHostStyle, resolveTheme, vegaConfig, } from "./theme"; diff --git a/core/src/raw_chart.ts b/core/src/raw_chart.ts index 0dbc8a8..5c19eeb 100644 --- a/core/src/raw_chart.ts +++ b/core/src/raw_chart.ts @@ -1,4 +1,3 @@ -import { takeAnnotations, withAnnotations } from "./annotations"; import { VegaChart } from "./chart_base"; import type { PresetName } from "./preset"; import { @@ -6,15 +5,15 @@ import { type VegaLiteSpec, type ZoomChannel, } from "./specs"; -import { type ChartTheme, fontScaleForWidth, vegaConfig } from "./theme"; +import { type ChartTheme, fontScaleForHost, vegaConfig } from "./theme"; import type { ThemeMode } from "./types"; /** - * Render a Vega-Lite spec. Optional top-level `annotations` (molplot - * extension) expand into ordinary VL layers so they pan/zoom with the chart. + * Render a plain Vega-Lite top-level spec. Labels/rules are ordinary VL marks + * in `layer` so they pan/zoom with the chart. */ export interface RawChartConfig { - /** A Vega-Lite top-level spec (plus optional `annotations`). */ + /** A Vega-Lite top-level spec. */ spec: VegaLiteSpec; preset?: PresetName; theme?: ThemeMode; @@ -61,11 +60,8 @@ export class RawChart extends VegaChart { theme: ChartTheme, sizeHint: { width: number; height: number }, ): VegaLiteSpec { - // Expand annotations → VL layers (scenegraph), then inject zoom params. - const taken = takeAnnotations({ - ...((this.spec ?? {}) as Record), - }); - const spec = withAnnotations(taken.spec as VegaLiteSpec, taken.annotations); + const scale = fontScaleForHost(sizeHint.width, theme.hostFontPx); + const spec = { ...(this.spec ?? {}) } as VegaLiteSpec; const channels = this.interactive ? continuousChannels(spec) : []; const derivedHeight = Math.max( 1, @@ -75,14 +71,13 @@ export class RawChart extends VegaChart { sizeHint.height > 1 ? Math.max(1, Math.round(sizeHint.height)) : derivedHeight; - const scale = fontScaleForWidth(sizeHint.width); const baseConfig = vegaConfig(theme, scale); const authorConfig = spec.config && typeof spec.config === "object" ? (spec.config as Record) : null; const config = authorConfig - ? deepMergeConfig(baseConfig, authorConfig) + ? deepMergeConfig(baseConfig, stripFrozenTypeSizes(authorConfig)) : baseConfig; const base: VegaLiteSpec = { @@ -101,12 +96,8 @@ export class RawChart extends VegaChart { ? (spec.layer as Record[]) : null; - // Layered charts (incl. annotation rule/text layers): put zoom params on - // the first unit layer only. Top-level `params` + multiple layers makes - // Vega-Lite emit duplicate selection signals (`zoomX_x`, …); vega.parse - // then throws and the whole chart fails to render. Layer-0 params still - // bind shared x/y scales (domainRaw), so pan/zoom and annotation layers - // move together. + // Layered specs: zoom params on first unit layer only (avoids duplicate + // zoomX_* signals when top-level params + multi-layer are combined). if (layers && layers.length > 0) { const nextLayers = layers.map((layer, i) => { if (i !== 0) return layer; @@ -126,7 +117,6 @@ export class RawChart extends VegaChart { } } -/** Keep author params; replace/add molplot zoom binds by name. */ function mergeZoomParams( existing: unknown, zoom: ReturnType, @@ -146,7 +136,37 @@ function mergeZoomParams( return [...kept, ...zoom]; } -/** Deep-merge plain config objects (arrays / scalars replaced, objects merged). */ +const FROZEN_TYPE_KEYS = new Set([ + "titleFontSize", + "labelFontSize", + "fontSize", + "titleLimit", + "labelLimit", + "titlePadding", + "labelPadding", + "tickSize", + "symbolSize", + "offset", + "rowPadding", + "columnPadding", +]); + +function stripFrozenTypeSizes( + over: Record, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(over)) { + if (key === "padding") continue; + if (FROZEN_TYPE_KEYS.has(key) && typeof value === "number") continue; + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + out[key] = stripFrozenTypeSizes(value as Record); + } else { + out[key] = value; + } + } + return out; +} + function deepMergeConfig( base: Record, over: Record, @@ -173,10 +193,6 @@ function deepMergeConfig( return out; } -/** - * Continuous x/y channels: merge top-level encoding (type/scale) with the - * first layer that declares fields (docs layered charts often split these). - */ function continuousChannels(spec: VegaLiteSpec): ZoomChannel[] { const top = spec.encoding as | Record @@ -190,7 +206,6 @@ function continuousChannels(spec: VegaLiteSpec): ZoomChannel[] { return (["x", "y"] as const).filter((channel) => { const type = top?.[channel]?.type ?? layerEnc?.[channel]?.type; if (type === "quantitative" || type === "temporal") return true; - // Field on a layer + scale/type on top still counts as continuous. const field = top?.[channel]?.field ?? layerEnc?.[channel]?.field; return Boolean(field && (top?.[channel]?.type || top?.[channel])); }); diff --git a/core/src/specs.ts b/core/src/specs.ts index ca39a6e..dadd582 100644 --- a/core/src/specs.ts +++ b/core/src/specs.ts @@ -1,6 +1,6 @@ import type { BarChartConfig, BarSeriesConfig } from "./bar_chart"; import type { GanttChartConfig } from "./gantt_chart"; -import { type ChartTheme, fontScaleForWidth, vegaConfig } from "./theme"; +import { type ChartTheme, fontScaleForHost, vegaConfig } from "./theme"; import type { AxisConfig, LineChartConfig, ScatterChartConfig } from "./types"; /** @@ -134,7 +134,10 @@ function themedConfig( theme: ChartTheme, width: number | "container", ): Record { - const scale = typeof width === "number" ? fontScaleForWidth(width) : 1; + const scale = + typeof width === "number" + ? fontScaleForHost(width, theme.hostFontPx ?? 13) + : 1; return vegaConfig(theme, scale); } diff --git a/core/src/theme.ts b/core/src/theme.ts index 4e836e8..170faea 100644 --- a/core/src/theme.ts +++ b/core/src/theme.ts @@ -11,6 +11,14 @@ export const CHART_PALETTE: readonly string[] = getPreset().palette.categorical; export const CHART_DEFAULT_COLOR = getPreset().palette.defaultColor; +/** CSS values read from the host page so docs type/color match the article. */ +export interface HostStyle { + /** Computed body font-size in px (e.g. `.md-typeset` ≈ 12.8). */ + fontSizePx: number; + fontFamily: string | null; + color: string | null; +} + export interface ChartTheme { background: "transparent"; font: { size: number; color: string; family: string }; @@ -33,6 +41,8 @@ export interface ChartTheme { /** The preset name this theme was resolved from. */ presetName: string; mode: "light" | "dark"; + /** Host body size used for {@link fontScaleForHost}. */ + hostFontPx: number; } function documentPrefersDark(): boolean { @@ -42,21 +52,64 @@ function documentPrefersDark(): boolean { ); } +/** + * Read type / ink from the surrounding page so docs charts match body copy + * instead of a frozen paper × 3× scale. + * + * Prefer `.md-typeset` (Material / Zensical article) when present. + */ +export function readHostStyle(el: HTMLElement | null | undefined): HostStyle { + if (!el || typeof getComputedStyle === "undefined") { + return { fontSizePx: 13, fontFamily: null, color: null }; + } + const host = + (el.closest(".md-typeset") as HTMLElement | null) ?? + (el.closest("article") as HTMLElement | null) ?? + el; + const cs = getComputedStyle(host); + const fontSizePx = parseFloat(cs.fontSize); + return { + fontSizePx: Number.isFinite(fontSizePx) && fontSizePx > 0 ? fontSizePx : 13, + fontFamily: cs.fontFamily?.trim() || null, + color: cs.color?.trim() || null, + }; +} + +/** + * Axis / label type stack: Times New Roman + math fonts (STIX / Latin Modern) + * so τ, Ų, subscripts match paper figures. Body prose may stay Inter; chart + * labels stay scientific serif. + */ +export const CHART_SERIF_STACK = + "Times New Roman, Times, STIX Two Text, STIXGeneral, " + + "Latin Modern Roman, 'Cambria Math', serif"; + /** * Resolve a theme mode (and optional named preset) to a concrete ChartTheme. * `auto` observes `` once at call time — for live tracking, * the chart classes set up a MutationObserver and call this on change. + * + * Host supplies **ink colour** and **body size** (for fontScale). Chart type + * family stays Times + math fonts — not the page sans — so axis titles read + * like paper figures next to Inter body copy. */ -export function resolveTheme(mode: ThemeMode, presetName?: string): ChartTheme { +export function resolveTheme( + mode: ThemeMode, + presetName?: string, + host?: HTMLElement | null, +): ChartTheme { const preset = getPreset(presetName); const dark = mode === "dark" || (mode === "auto" && documentPrefersDark()); const m = dark ? preset.modes.dark : preset.modes.light; + const hostStyle = readHostStyle(host ?? null); + // Scientific axis type: Times + math fonts (not the page sans). + const family = CHART_SERIF_STACK; return { background: "transparent", font: { size: preset.typography.size.base, - color: m.foreground, - family: preset.typography.family, + color: hostStyle.color ?? m.foreground, + family, }, axis: { gridColor: m.gridColorSolid, tickColor: m.tickColor }, palette: preset.palette.categorical, @@ -73,27 +126,45 @@ export function resolveTheme(mode: ThemeMode, presetName?: string): ChartTheme { fontSize: { ...preset.typography.size }, presetName: preset.name, mode: dark ? "dark" : "light", + hostFontPx: hostStyle.fontSizePx, }; } /** * Paper design width (matches `view.continuousWidth`). At this width the - * preset type scale (≈9–12 px) is correct for a 3.5″ figure; wider web hosts - * need a scale factor so axis labels stay readable. + * preset type scale (≈9–12 px) is correct for a 3.5″ figure. */ export const MOLPLOT_DESIGN_WIDTH = 320; /** - * Scale factor for screen / docs hosts. + * Scale factor so chart type matches the **host page body**, not a paper + * billboard. * - * Paper is ~9–12 px at {@link MOLPLOT_DESIGN_WIDTH}. Docs need much larger - * type (axis title / tick / annotation) — floor **3×** paper at design width, - * track host width up to **4.5×**. Fixed `fontSize` in a fence freezes size. + * Paper tick is ≈9 px. Docs body (`.md-typeset`) is typically 0.8rem ≈ 12–13 px. + * We target tick ≈ 0.95 × body, title ≈ 1.05 × body — readable next to prose + * without crushing legends or eating the plot. + * + * Width only nudges ±10% (narrow sidebars vs full column). + */ +export function fontScaleForHost(width: number, bodyPx = 13): number { + const paperTick = 9; + const body = Number.isFinite(bodyPx) && bodyPx > 0 ? bodyPx : 13; + const targetTick = body * 0.95; + let scale = targetTick / paperTick; + if (Number.isFinite(width) && width > 0) { + const wf = Math.min(1.1, Math.max(0.9, width / 560)); + scale *= wf; + } + // Keep a readable floor / modest ceiling (never the old 3–4.5× paper blow-up). + return Math.round(Math.min(1.85, Math.max(1.15, scale)) * 100) / 100; +} + +/** + * Width-only scale with a neutral body size. Prefer + * {@link fontScaleForHost} when the host element is known. */ export function fontScaleForWidth(width: number): number { - if (!Number.isFinite(width) || width <= 0) return 3; - const tracked = width / MOLPLOT_DESIGN_WIDTH; - return Math.min(4.5, Math.max(3, 3 * tracked)); + return fontScaleForHost(width, 13); } /** @@ -104,7 +175,7 @@ export function fontScaleForWidth(width: number): number { * scale, and grid styling. * * @param fontScale - multiplies every type size (1 = paper preset). Web - * hosts should pass {@link fontScaleForWidth} so labels track the chart size. + * hosts should pass {@link fontScaleForHost} so labels track page body type. */ export function vegaConfig( theme: ChartTheme, @@ -112,12 +183,18 @@ export function vegaConfig( ): Record { const scale = Number.isFinite(fontScale) && fontScale > 0 ? fontScale : 1; const px = (n: number) => Math.round(n * scale * 10) / 10; - // Modest outer pad — large pads + multi-legend + fixed box crushes the plot. - const pad = Math.max(6, Math.round(6 * scale)); + // Equal outer pad on all four sides (CSS host margin is separate). + const pad = Math.max(6, Math.round(4 * scale + 3)); + const serif = theme.font.family; return { background: "transparent", - font: theme.font.family, - padding: { left: pad, right: pad, top: pad, bottom: pad }, + font: serif, + padding: { + left: pad, + right: pad, + top: pad, + bottom: pad, + }, axis: { labelColor: theme.font.color, titleColor: theme.font.color, @@ -127,16 +204,16 @@ export function vegaConfig( gridWidth: 0.5, labelFontSize: px(theme.fontSize.tick), titleFontSize: px(theme.fontSize.label), - labelFont: theme.font.family, - titleFont: theme.font.family, + labelFont: serif, + titleFont: serif, titleFontStyle: "normal", labelFontStyle: "normal", - titlePadding: Math.round(8 * scale), - labelPadding: Math.round(4 * scale), + titlePadding: Math.round(5 * scale + 2), + labelPadding: Math.round(3 * scale + 1), grid: true, - tickSize: Math.max(5, Math.round(4 * scale)), - labelLimit: Math.round(220 * scale), - titleLimit: Math.round(280 * scale), + tickSize: Math.max(4, Math.round(3 * scale)), + labelLimit: Math.round(200 * scale), + titleLimit: Math.round(240 * scale), labelOverlap: true, labelFlush: true, }, @@ -145,16 +222,16 @@ export function vegaConfig( titleColor: theme.font.color, labelFontSize: px(theme.fontSize.legend), titleFontSize: px(theme.fontSize.legend), - labelFont: theme.font.family, - titleFont: theme.font.family, + labelFont: serif, + titleFont: serif, symbolType: "circle", titleLimit: Math.round(160 * scale), - labelLimit: Math.round(120 * scale), - padding: Math.round(4 * scale), - offset: Math.round(6 * scale), + labelLimit: Math.round(140 * scale), + padding: Math.round(3 * scale), + offset: Math.round(4 * scale), rowPadding: Math.round(2 * scale), - columnPadding: Math.round(4 * scale), - symbolSize: Math.round(48 * scale), + columnPadding: Math.round(6 * scale), + symbolSize: Math.round(40 * scale), }, title: { color: theme.font.color, diff --git a/core/tests/annotations.test.ts b/core/tests/annotations.test.ts deleted file mode 100644 index fbe924c..0000000 --- a/core/tests/annotations.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, it } from "@rstest/core"; -import { - annotationLayers, - scaleBar, - takeAnnotations, - withAnnotations, -} from "../src/annotations"; - -describe("annotations", () => { - it("scaleBar expands to VL rule + cap + label layers", () => { - const layers = annotationLayers([ - scaleBar({ - x: 0.18, - y: 0.03, - x2: 0.65, - y2: 0.42, - label: "ballistic", - }), - ]); - expect(layers.length).toBe(3); - expect((layers[0].mark as { type: string }).type).toBe("rule"); - expect((layers[1].mark as { type: string }).type).toBe("rule"); - expect((layers[2].mark as { type: string }).type).toBe("text"); - }); - - it("withAnnotations appends layers so marks pan with the chart", () => { - const out = withAnnotations( - { - layer: [ - { - mark: "line", - encoding: { - x: { field: "x", type: "quantitative" }, - y: { field: "y", type: "quantitative" }, - }, - }, - ], - }, - [scaleBar({ x: 1, y: 1, x2: 2, y2: 2, label: "a" })], - ); - expect(Array.isArray(out.layer)).toBe(true); - expect((out.layer as unknown[]).length).toBe(1 + 3); - }); - - it("takeAnnotations strips the extension key", () => { - const { spec, annotations } = takeAnnotations({ - mark: "point", - annotations: [{ kind: "scaleBar", x: 0, y: 0, length: 1 }], - }); - expect(annotations).toHaveLength(1); - expect("annotations" in spec).toBe(false); - }); - - it("layered annotations + layer0 zoom params compile without duplicate signals", async () => { - // Regression: top-level params + multi-layer → VL emits duplicate - // zoomX_x / zoomY_y signals and vega.parse throws (chart invisible). - const { compile } = await import("vega-lite"); - const { parse } = await import("vega"); - const { interactionParams } = await import("../src/specs"); - - const expanded = withAnnotations( - { - encoding: { - x: { - type: "quantitative", - scale: { type: "log", domain: [0.1, 100] }, - }, - y: { - type: "quantitative", - scale: { type: "log", domain: [0.008, 120] }, - }, - }, - layer: [ - { - data: { - values: [ - { x: 0.1, y: 0.01 }, - { x: 1, y: 1 }, - { x: 10, y: 10 }, - ], - }, - mark: { type: "line" }, - encoding: { x: { field: "x" }, y: { field: "y" } }, - }, - ], - }, - [ - scaleBar({ - x: 0.18, - y: 0.03, - x2: 0.65, - y2: 0.42, - label: "ballistic", - }), - ], - ); - const layers = (expanded.layer as Record[]).map( - (layer, i) => - i === 0 ? { ...layer, params: interactionParams(["x", "y"]) } : layer, - ); - const full = { - $schema: "https://vega.github.io/schema/vega-lite/v5.json", - width: 400, - height: 225, - encoding: expanded.encoding, - layer: layers, - }; - const compiled = compile(full as never).spec as { - signals?: { name: string }[]; - scales?: { name: string; domainRaw?: unknown }[]; - }; - const names = (compiled.signals ?? []).map((s) => s.name); - const dups = names.filter((n, i) => names.indexOf(n) !== i); - expect(dups).toEqual([]); - expect( - compiled.scales?.find((s) => s.name === "x")?.domainRaw, - ).toBeTruthy(); - // Must parse: this is what vega-embed does. - expect(() => parse(compiled as never)).not.toThrow(); - }); -}); diff --git a/core/tests/preset.test.ts b/core/tests/preset.test.ts index 9d5cfdb..50f77c9 100644 --- a/core/tests/preset.test.ts +++ b/core/tests/preset.test.ts @@ -62,15 +62,31 @@ describe("vegaConfig", () => { }); }); +describe("fontScaleForHost", () => { + it("matches page body type (~0.95× body / 9 px paper tick)", async () => { + const { fontScaleForHost } = await import("../src/theme"); + // Docs .md-typeset ≈ 12.8 px → tick target ≈ 12.2 → scale ≈ 1.35 + const s = fontScaleForHost(560, 12.8); + expect(s).toBeGreaterThanOrEqual(1.15); + expect(s).toBeLessThanOrEqual(1.85); + expect(s).toBeCloseTo((12.8 * 0.95) / 9, 1); + }); + + it("nudges with width but stays within a modest band", async () => { + const { fontScaleForHost } = await import("../src/theme"); + const narrow = fontScaleForHost(320, 13); + const wide = fontScaleForHost(800, 13); + expect(wide).toBeGreaterThanOrEqual(narrow); + expect(wide).toBeLessThanOrEqual(1.85); + expect(narrow).toBeGreaterThanOrEqual(1.15); + }); +}); + describe("fontScaleForWidth", () => { - it("is 3× paper at design width and grows with host (cap 4.5×)", async () => { - const { fontScaleForWidth, MOLPLOT_DESIGN_WIDTH } = await import( + it("delegates to fontScaleForHost with a neutral body size", async () => { + const { fontScaleForWidth, fontScaleForHost } = await import( "../src/theme" ); - expect(fontScaleForWidth(MOLPLOT_DESIGN_WIDTH)).toBe(3); - expect(fontScaleForWidth(MOLPLOT_DESIGN_WIDTH * 1.2)).toBeCloseTo(3.6, 5); - expect(fontScaleForWidth(MOLPLOT_DESIGN_WIDTH * 1.8)).toBe(4.5); - expect(fontScaleForWidth(MOLPLOT_DESIGN_WIDTH * 3)).toBe(4.5); - expect(fontScaleForWidth(0)).toBe(3); + expect(fontScaleForWidth(560)).toBe(fontScaleForHost(560, 13)); }); }); diff --git a/core/tests/raw_chart.interactive.test.ts b/core/tests/raw_chart.interactive.test.ts index fdf3138..7152173 100644 --- a/core/tests/raw_chart.interactive.test.ts +++ b/core/tests/raw_chart.interactive.test.ts @@ -52,7 +52,7 @@ function zoomPlacement( if (channels.length === 0) return "none"; const layers = Array.isArray(spec.layer) ? spec.layer : null; // Multi-layer always uses layer0 — top-level params duplicate VL selection - // signals when annotation (or any extra) layers are present. + // signals when extra layers are present. if (layers && layers.length > 0) return "layer0"; return "top"; } @@ -117,7 +117,7 @@ describe("RawChart interactive injection", () => { ).toBe("layer0"); }); - it("places zoom params on layer0 when annotation layers are present", () => { + it("places zoom params on layer0 when extra rule layers are present", () => { expect( zoomPlacement({ encoding: { diff --git a/package.json b/package.json index 685bfb6..018c44f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "molplot", - "version": "0.1.4", + "version": "0.1.5", "description": "MolPlot \u2014 Vega-Lite scientific charting with a unified, matplotlib-portable preset", "author": "Roy Kid", "license": "BSD-3-Clause", diff --git a/python/pyproject.toml b/python/pyproject.toml index 149f783..94ba6ac 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "molcrafts-molplot" -version = "0.1.4" +version = "0.1.5" description = "Unified scientific charting: scienceplots + a matplotlib-portable Vega-Lite preset" readme = "README.md" license = "BSD-3-Clause" diff --git a/python/src/molplot/__init__.py b/python/src/molplot/__init__.py index 50f1755..ce07755 100644 --- a/python/src/molplot/__init__.py +++ b/python/src/molplot/__init__.py @@ -21,13 +21,6 @@ from __future__ import annotations -from .annotations import ( - annotation_layers, - arrow, - scale_bar, - take_annotations, - with_annotations, -) from .charts import bar, gantt, line, scatter from .convert import to_png, to_svg from .palette import color, cycle, default_color, diverging, palette, sequential @@ -70,12 +63,6 @@ "gantt_spec", "vega_config", "VL_SCHEMA", - # annotations (matplotlib-style: one call = full artist) - "scale_bar", - "arrow", - "annotation_layers", - "with_annotations", - "take_annotations", # render "render", "line", diff --git a/python/src/molplot/annotations.py b/python/src/molplot/annotations.py deleted file mode 100644 index f972b7a..0000000 --- a/python/src/molplot/annotations.py +++ /dev/null @@ -1,536 +0,0 @@ -"""Chart annotations — one call draws a complete artist (mirrors TS). - -Design follows matplotlib: - -* ``scale_bar(...)`` ≈ ``FancyArrowPatch(..., arrowstyle='|-|')`` plus a - label — **one** object owns the spine, both end-caps, and the text. - Callers never draw caps by hand. -* ``arrow(...)`` ≈ ``ax.annotate(..., arrowprops=dict(arrowstyle='->'))``. - -Expand with :func:`with_annotations` / a top-level ``annotations`` list on a -Vega-Lite fence or :class:`RawChart` payload. The expansion always emits -bar + caps + label as a single unit. -""" - -from __future__ import annotations - -import math -from typing import Any, Literal, Mapping, Sequence, TypedDict - -__all__ = [ - "ScaleBarAnnotation", - "ArrowAnnotation", - "Annotation", - "scale_bar", - "arrow", - "annotation_layers", - "with_annotations", - "take_annotations", -] - - -class ScaleBarAnnotation(TypedDict, total=False): - """One complete ``|———|`` (spine + ⊥ caps + optional label).""" - - kind: Literal["scaleBar"] - x: float - y: float - length: float - x2: float - y2: float - label: str - color: str - strokeWidth: float - orientation: Literal["horizontal", "vertical", "along"] - tick: float - tickRatio: float - capLog: float - offsetLog: float - - -class ArrowAnnotation(TypedDict, total=False): - kind: Literal["arrow"] - x: float - y: float - x2: float - y2: float - label: str - color: str - strokeWidth: float - tipSize: float - - -Annotation = ScaleBarAnnotation | ArrowAnnotation - -_SERIF = "Times New Roman, Times, STIX Two Text, STIXGeneral, serif" - - -def scale_bar( - x: float, - y: float, - *, - x2: float | None = None, - y2: float | None = None, - length: float | None = None, - label: str | None = None, - orientation: Literal["horizontal", "vertical", "along"] | None = None, - offset: float = 0.42, - capsize: float = 0.16, - color: str = "#18432b", - linewidth: float = 1.8, - tick: float | None = None, - tick_ratio: float | None = None, -) -> ScaleBarAnnotation: - """Build one complete ``|———|`` annotation (matplotlib ``arrowstyle='|-|'``). - - Parameters - ---------- - x, y - Start in data coordinates. For ``orientation='along'`` this is a point - **on the curve** (reference chord start). - x2, y2 - End of the chord (required for ``along``; optional for axis-aligned - when ``length`` is given). - length - Axis-aligned length when ``x2``/``y2`` are omitted. - label - Text drawn with the bar (Times roman). Owned by this artist — do not - add a separate text layer. - orientation - ``'along'`` — chord parallel to ``(x,y)→(x2,y2)``, offset off the - path by ``offset`` in log–log space, end-caps ⊥ bar (default when - both ends are given). - ``'horizontal'`` / ``'vertical'`` — axis-aligned size bar. - offset - Log-space normal offset from the reference chord (``along`` only). - Default ``0.42`` so the bar does not sit on the curve. ``0`` = on chord. - capsize - Log-space half-length of each end-cap (``along``). Default ``0.16``. - color, linewidth - Stroke style for spine and caps together. - - Returns - ------- - ScaleBarAnnotation - Pass inside ``annotations=[...]`` or to :func:`with_annotations`. - Expansion yields spine + both caps + label — never hand-draw caps. - """ - if orientation is None: - orientation = ( - "along" - if x2 is not None and y2 is not None - else "horizontal" - ) - out: ScaleBarAnnotation = { - "kind": "scaleBar", - "x": float(x), - "y": float(y), - "color": color, - "strokeWidth": float(linewidth), - "orientation": orientation, - } - if x2 is not None: - out["x2"] = float(x2) - if y2 is not None: - out["y2"] = float(y2) - if length is not None: - out["length"] = float(length) - if label is not None: - out["label"] = label - if orientation == "along": - out["offsetLog"] = float(offset) - out["capLog"] = float(capsize) - if tick is not None: - out["tick"] = float(tick) - if tick_ratio is not None: - out["tickRatio"] = float(tick_ratio) - return out - - -def arrow( - x: float, - y: float, - x2: float, - y2: float, - *, - label: str | None = None, - color: str = "#18432b", - linewidth: float = 1.6, - tip_size: float = 55, -) -> ArrowAnnotation: - """Build one complete arrow (matplotlib ``arrowstyle='->'``).""" - out: ArrowAnnotation = { - "kind": "arrow", - "x": float(x), - "y": float(y), - "x2": float(x2), - "y2": float(y2), - "color": color, - "strokeWidth": float(linewidth), - "tipSize": float(tip_size), - } - if label is not None: - out["label"] = label - return out - - -def _log_perp_cap( - x: float, y: float, x0: float, y0: float, x1: float, y1: float, s: float -) -> dict[str, float]: - dx = math.log(x1 / x0) - dy = math.log(y1 / y0) - n = math.hypot(dx, dy) or 1.0 - px, py = (-dy / n) * s, (dx / n) * s - return { - "x": math.exp(math.log(x) + px), - "y": math.exp(math.log(y) + py), - "x2": math.exp(math.log(x) - px), - "y2": math.exp(math.log(y) - py), - } - - -def _scale_bar_layers(a: Mapping[str, Any]) -> list[dict[str, Any]]: - """Expand **one** scaleBar into spine + caps + label (internal).""" - color = a.get("color") or "#18432b" - sw = a.get("strokeWidth") or 1.8 - orient = a.get("orientation") or "horizontal" - x2, y2 = a.get("x2"), a.get("y2") - along = orient == "along" or ( - x2 is not None and y2 is not None and orient not in ("horizontal", "vertical") - ) - length = float(a["length"]) if a.get("length") is not None else 0.0 - tick = a.get("tick") - if tick is None: - tick = abs(length) * 0.08 if length else 0.05 - tick_ratio = a.get("tickRatio") - x, y = float(a["x"]), float(a["y"]) - layers: list[dict[str, Any]] = [] - - if along and x2 is not None and y2 is not None: - rx0, ry0, rx1, ry1 = x, y, float(x2), float(y2) - dx = math.log(rx1 / rx0) - dy = math.log(ry1 / ry0) - n = math.hypot(dx, dy) or 1.0 - ux, uy = dx / n, dy / n - side = 1 - if math.exp(0.5 * (math.log(ry0) + math.log(ry1)) - ux * 0.1) > math.exp( - 0.5 * (math.log(ry0) + math.log(ry1)) + ux * 0.1 - ): - side = -1 - s_off = float(a["offsetLog"]) if a.get("offsetLog") is not None else 0.42 - ox, oy = -uy * s_off * side, ux * s_off * side - x0 = math.exp(math.log(rx0) + ox) - y0 = math.exp(math.log(ry0) + oy) - x1 = math.exp(math.log(rx1) + ox) - y1 = math.exp(math.log(ry1) + oy) - s_cap = float(a["capLog"]) if a.get("capLog") is not None else 0.16 - # spine - layers.append( - { - "data": {"values": [{"x": x0, "y": y0, "x2": x1, "y2": y1}]}, - "mark": { - "type": "rule", - "strokeWidth": sw, - "color": color, - "strokeCap": "butt", - }, - "encoding": { - "x": {"field": "x", "type": "quantitative"}, - "y": {"field": "y", "type": "quantitative"}, - "x2": {"field": "x2"}, - "y2": {"field": "y2"}, - }, - } - ) - # both end-caps (owned by this scale_bar — not a separate API call) - layers.append( - { - "data": { - "values": [ - _log_perp_cap(x0, y0, x0, y0, x1, y1, s_cap), - _log_perp_cap(x1, y1, x0, y0, x1, y1, s_cap), - ] - }, - "mark": {"type": "rule", "strokeWidth": sw, "color": color}, - "encoding": { - "x": {"field": "x", "type": "quantitative"}, - "y": {"field": "y", "type": "quantitative"}, - "x2": {"field": "x2"}, - "y2": {"field": "y2"}, - }, - } - ) - if a.get("label"): - s_lab = s_off + 0.32 - lx = math.exp( - 0.5 * (math.log(rx0) + math.log(rx1)) + -uy * s_lab * side - ) - ly = math.exp( - 0.5 * (math.log(ry0) + math.log(ry1)) + ux * s_lab * side - ) - layers.append( - { - "data": {"values": [{"x": lx, "y": ly, "label": a["label"]}]}, - "mark": { - "type": "text", - "font": _SERIF, - "fontStyle": "normal", - "color": color, - "align": "center", - "baseline": "middle", - }, - "encoding": { - "x": {"field": "x", "type": "quantitative"}, - "y": {"field": "y", "type": "quantitative"}, - "text": {"field": "label", "type": "nominal"}, - }, - } - ) - return layers - - horizontal = orient == "horizontal" - if horizontal: - x0 = x - x1 = float(x2) if x2 is not None else x + length - mid = (x0 + x1) / 2 - if tick_ratio is not None: - y_lo, y_hi = y / float(tick_ratio), y * float(tick_ratio) - else: - y_lo, y_hi = y - tick, y + tick - layers.append( - { - "data": {"values": [{"x": x0, "x2": x1, "y": y}]}, - "mark": { - "type": "rule", - "strokeWidth": sw, - "color": color, - "strokeCap": "butt", - }, - "encoding": { - "x": {"field": "x", "type": "quantitative"}, - "x2": {"field": "x2"}, - "y": {"field": "y", "type": "quantitative"}, - }, - } - ) - layers.append( - { - "data": { - "values": [ - {"x": x0, "y": y_lo, "y2": y_hi}, - {"x": x1, "y": y_lo, "y2": y_hi}, - ] - }, - "mark": {"type": "rule", "strokeWidth": sw, "color": color}, - "encoding": { - "x": {"field": "x", "type": "quantitative"}, - "y": {"field": "y", "type": "quantitative"}, - "y2": {"field": "y2"}, - }, - } - ) - if a.get("label"): - layers.append( - { - "data": {"values": [{"x": mid, "y": y, "label": a["label"]}]}, - "mark": { - "type": "text", - "dy": -10, - "font": _SERIF, - "fontStyle": "normal", - "color": color, - "align": "center", - "baseline": "bottom", - }, - "encoding": { - "x": {"field": "x", "type": "quantitative"}, - "y": {"field": "y", "type": "quantitative"}, - "text": {"field": "label", "type": "nominal"}, - }, - } - ) - return layers - - # vertical - y0 = y - y1 = float(y2) if y2 is not None else y + length - mid = (y0 + y1) / 2 - if tick_ratio is not None: - x_lo, x_hi = x / float(tick_ratio), x * float(tick_ratio) - else: - x_lo, x_hi = x - tick, x + tick - layers.append( - { - "data": {"values": [{"x": x, "y": y0, "y2": y1}]}, - "mark": { - "type": "rule", - "strokeWidth": sw, - "color": color, - "strokeCap": "butt", - }, - "encoding": { - "x": {"field": "x", "type": "quantitative"}, - "y": {"field": "y", "type": "quantitative"}, - "y2": {"field": "y2"}, - }, - } - ) - layers.append( - { - "data": { - "values": [ - {"x": x_lo, "x2": x_hi, "y": y0}, - {"x": x_lo, "x2": x_hi, "y": y1}, - ] - }, - "mark": {"type": "rule", "strokeWidth": sw, "color": color}, - "encoding": { - "x": {"field": "x", "type": "quantitative"}, - "x2": {"field": "x2"}, - "y": {"field": "y", "type": "quantitative"}, - }, - } - ) - if a.get("label"): - layers.append( - { - "data": {"values": [{"x": x, "y": mid, "label": a["label"]}]}, - "mark": { - "type": "text", - "dx": 10, - "font": _SERIF, - "fontStyle": "normal", - "color": color, - "align": "left", - "baseline": "middle", - }, - "encoding": { - "x": {"field": "x", "type": "quantitative"}, - "y": {"field": "y", "type": "quantitative"}, - "text": {"field": "label", "type": "nominal"}, - }, - } - ) - return layers - - -def _arrow_layers(a: Mapping[str, Any]) -> list[dict[str, Any]]: - color = a.get("color") or "#18432b" - sw = a.get("strokeWidth") or 1.6 - tip_size = a.get("tipSize") or 55 - x, y = float(a["x"]), float(a["y"]) - x2, y2 = float(a["x2"]), float(a["y2"]) - angle = math.degrees(math.atan2(y2 - y, x2 - x)) + 90 - layers: list[dict[str, Any]] = [ - { - "data": {"values": [{"x": x, "y": y, "x2": x2, "y2": y2}]}, - "mark": { - "type": "rule", - "strokeWidth": sw, - "color": color, - "strokeCap": "round", - }, - "encoding": { - "x": {"field": "x", "type": "quantitative"}, - "y": {"field": "y", "type": "quantitative"}, - "x2": {"field": "x2"}, - "y2": {"field": "y2"}, - }, - }, - { - "data": {"values": [{"x": x2, "y": y2, "angle": angle}]}, - "mark": { - "type": "point", - "shape": "triangle", - "filled": True, - "size": tip_size, - "color": color, - }, - "encoding": { - "x": {"field": "x", "type": "quantitative"}, - "y": {"field": "y", "type": "quantitative"}, - "angle": {"field": "angle", "type": "quantitative"}, - }, - }, - ] - if a.get("label"): - layers.append( - { - "data": { - "values": [ - { - "x": (x + x2) / 2, - "y": (y + y2) / 2, - "label": a["label"], - } - ] - }, - "mark": { - "type": "text", - "dy": -8, - "font": _SERIF, - "fontStyle": "normal", - "color": color, - "align": "center", - "baseline": "bottom", - }, - "encoding": { - "x": {"field": "x", "type": "quantitative"}, - "y": {"field": "y", "type": "quantitative"}, - "text": {"field": "label", "type": "nominal"}, - }, - } - ) - return layers - - -def annotation_layers(annotations: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: - """Expand each annotation into its full set of VL layers.""" - out: list[dict[str, Any]] = [] - for a in annotations: - kind = a.get("kind") - if kind == "scaleBar": - out.extend(_scale_bar_layers(a)) - elif kind == "arrow": - out.extend(_arrow_layers(a)) - return out - - -def with_annotations( - spec: dict[str, Any], - annotations: Sequence[Mapping[str, Any]] | None, -) -> dict[str, Any]: - """Merge annotation layers into a unit or layered Vega-Lite spec.""" - if not annotations: - return spec - extra = annotation_layers(annotations) - if not extra: - return spec - if isinstance(spec.get("layer"), list): - return {**spec, "layer": [*spec["layer"], *extra]} - if "mark" in spec or "encoding" in spec: - base = { - k: spec[k] - for k in ("data", "mark", "encoding", "transform", "params") - if k in spec - } - rest = {k: v for k, v in spec.items() if k not in base} - return {**rest, "layer": [base, *extra]} - return {**spec, "layer": extra} - - -def take_annotations( - spec: dict[str, Any], -) -> tuple[dict[str, Any], list[Annotation]]: - """Pull top-level ``annotations`` off a payload; return ``(spec, list)``.""" - raw = spec.get("annotations") - if not isinstance(raw, list) or not raw: - cleaned = {k: v for k, v in spec.items() if k != "annotations"} - return cleaned, [] - annotations: list[Annotation] = [ - a # type: ignore[misc] - for a in raw - if isinstance(a, dict) and a.get("kind") in ("scaleBar", "arrow") - ] - cleaned = {k: v for k, v in spec.items() if k != "annotations"} - return cleaned, annotations diff --git a/python/src/molplot/vlmpl/interp.py b/python/src/molplot/vlmpl/interp.py index 09492e5..35bacab 100644 --- a/python/src/molplot/vlmpl/interp.py +++ b/python/src/molplot/vlmpl/interp.py @@ -10,7 +10,6 @@ from contextlib import nullcontext from typing import Any -from ..annotations import take_annotations, with_annotations from ..preset import DEFAULT_PRESET, Mode from ..style import style as _style from .axes import apply_axes @@ -39,10 +38,7 @@ def render( >>> spec = molplot.line_spec([{"id": "a", "x": [0, 1, 2], "y": [1, 3, 2]}]) >>> fig, ax = molplot.render(spec) """ - cleaned, annotations = take_annotations(spec) - if annotations: - cleaned = with_annotations(cleaned, annotations) - units = normalize(cleaned) + units = normalize(spec) ctx = _style(preset, mode) if apply_style else nullcontext() with ctx: fig, ax = _ensure_ax(ax)