diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 31a55217b..aeeef83f1 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -18,4 +18,7 @@ runs: run: | corepack enable pnpm install - for ext in $EXTENSIONS; do pnpm -C "extensions/$ext" install; done + # --ignore-workspace: a freshly released dep trips pnpm's cooldown + # (minimumReleaseAge), auto-creating a pnpm-workspace.yaml that makes these + # standalone extension installs no-ops (skipping anywidget/@types/node/matterviz). + for ext in $EXTENSIONS; do pnpm -C "extensions/$ext" install --ignore-workspace; done diff --git a/.github/workflows/test-dash.yml b/.github/workflows/test-dash.yml index 579c2aa54..d781328d5 100644 --- a/.github/workflows/test-dash.yml +++ b/.github/workflows/test-dash.yml @@ -50,10 +50,12 @@ jobs: run: corepack enable && pnpm install && pnpm run package:dist working-directory: . - # Install dash extension deps + # --ignore-workspace: the root install above can auto-create a pnpm-workspace.yaml + # (pnpm cooldown for freshly released deps) that makes this standalone install a + # no-op, leaving node_modules missing (matterviz/app.css then fails to resolve). - name: Install Dash extension dependencies run: | - pnpm install + pnpm install --ignore-workspace pip install -e ".[dev]" # Sync typed wrappers using local matterviz dist (not npm version) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 94471d3d8..9058d9817 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ default_install_hook_types: [pre-commit, commit-msg] repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.17 + rev: v0.15.19 hooks: - id: ruff-check args: [--fix] @@ -37,19 +37,13 @@ repos: hooks: - id: vp-fmt name: Format (oxfmt) - entry: npx --package=vite-plus vp fmt --write - types_or: [file] - language: system - pass_filenames: false - - id: vp-lint - name: Lint (oxlint) - entry: npx --package=vite-plus vp lint --fix + entry: npx --package=vite-plus vp check --fix types_or: [file] language: system pass_filenames: false - id: svelte-check name: Svelte type check - entry: bash -c "npx svelte-kit sync && npx svelte-check-rs --threshold error --ignore '.claude/**'" + entry: bash -c "npx svelte-kit sync && npx svelte-check --threshold error" language: system types_or: [ts, svelte] pass_filenames: false @@ -73,7 +67,7 @@ repos: - --check-filenames - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.48.0 + rev: v0.49.0 hooks: - id: markdownlint # MD013: line too long diff --git a/extensions/anywidget/package.json b/extensions/anywidget/package.json index ed4c68640..97afb2ead 100644 --- a/extensions/anywidget/package.json +++ b/extensions/anywidget/package.json @@ -25,12 +25,12 @@ "@janosh/vite-config": "github:janosh/dotfiles", "@spglib/moyo-wasm": "^0.12.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", - "@types/node": "^25.9.3", + "@types/node": "^26.0.0", "anywidget": "^0.11.0", "matterviz": "file:../..", - "svelte": "^5.56.3", + "svelte": "^5.56.4", "svelte-multiselect": "^11.7.2", - "vite": "^8.0.16", + "vite": "^8.1.0", "vite-plus": "latest" }, "packageManager": "pnpm@11.5.0" diff --git a/extensions/dash/matterviz_dash_components/typed.py b/extensions/dash/matterviz_dash_components/typed.py index 0c65b9e1b..ce3715dae 100644 --- a/extensions/dash/matterviz_dash_components/typed.py +++ b/extensions/dash/matterviz_dash_components/typed.py @@ -52,6 +52,7 @@ def __init__( loading: bool | None = None, measure_mode: Any | None = None, measured_sites: list[int] | None = None, + multi_view: bool | None = None, performance_mode: Any | None = None, png_dpi: float | None = None, reset_text: str | None = None, @@ -64,6 +65,7 @@ def __init__( structure_string: str | None = None, sym_data: Any | None = None, symmetry_settings: dict | None = None, + views: list | None = None, volumetric_data: list | None = None, width: float | None = None, mv_props: dict | None = None, @@ -133,6 +135,8 @@ def __init__( mv_props["measure_mode"] = measure_mode if measured_sites is not None: mv_props["measured_sites"] = measured_sites + if multi_view is not None: + mv_props["multi_view"] = multi_view if performance_mode is not None: mv_props["performance_mode"] = performance_mode if png_dpi is not None: @@ -157,6 +161,8 @@ def __init__( mv_props["sym_data"] = sym_data if symmetry_settings is not None: mv_props["symmetry_settings"] = symmetry_settings + if views is not None: + mv_props["views"] = views if volumetric_data is not None: mv_props["volumetric_data"] = volumetric_data if width is not None: @@ -1262,6 +1268,7 @@ def __init__( legend: Any | None = None, line_kwargs: Any | None = None, line_tween: Any | None = None, + marginals: Any | None = None, pan: Any | None = None, path_mode: Any | None = None, point_tween: Any | None = None, @@ -1328,6 +1335,8 @@ def __init__( mv_props["line_kwargs"] = line_kwargs if line_tween is not None: mv_props["line_tween"] = line_tween + if marginals is not None: + mv_props["marginals"] = marginals if pan is not None: mv_props["pan"] = pan if path_mode is not None: @@ -1414,6 +1423,7 @@ def __init__( label_placement_config: dict | None = None, legend: Any | None = None, line_tween: Any | None = None, + marginals: Any | None = None, normalize: Any | None = None, orientation: Any | None = None, pan: Any | None = None, @@ -1476,6 +1486,8 @@ def __init__( mv_props["legend"] = legend if line_tween is not None: mv_props["line_tween"] = line_tween + if marginals is not None: + mv_props["marginals"] = marginals if normalize is not None: mv_props["normalize"] = normalize if orientation is not None: @@ -1563,6 +1575,7 @@ def __init__( label_placement_config: dict | None = None, legend: Any | None = None, line_tween: Any | None = None, + marginals: Any | None = None, pan: Any | None = None, point_tween: Any | None = None, ref_lines: list | None = None, @@ -1606,6 +1619,8 @@ def __init__( mv_props["legend"] = legend if line_tween is not None: mv_props["line_tween"] = line_tween + if marginals is not None: + mv_props["marginals"] = marginals if pan is not None: mv_props["pan"] = pan if point_tween is not None: @@ -1661,6 +1676,7 @@ def __init__( bins: float | None = None, data_loader: Any | None = None, legend: Any | None = None, + marginals: Any | None = None, mode: Any | None = None, pan: Any | None = None, ref_lines: list | None = None, @@ -1686,6 +1702,8 @@ def __init__( mv_props["data_loader"] = data_loader if legend is not None: mv_props["legend"] = legend + if marginals is not None: + mv_props["marginals"] = marginals if mode is not None: mv_props["mode"] = mode if pan is not None: diff --git a/extensions/dash/package.json b/extensions/dash/package.json index 6c969ab3b..4539cdc1c 100644 --- a/extensions/dash/package.json +++ b/extensions/dash/package.json @@ -20,10 +20,10 @@ "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.1.2", "@types/prop-types": "^15.7.15", - "@types/react": "^19.2.14", - "svelte": "^5.55.8", + "@types/react": "^19.2.17", + "svelte": "^5.56.4", "typescript": "^6.0.3", - "vite": "^8.0.13" + "vite": "^8.1.0" }, "peerDependencies": { "react": ">=16.14.0", diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index edc64c311..3c9e0bbbf 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -23,12 +23,12 @@ }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.1.2", - "@types/node": "^25.9.3", - "@types/vscode": "^1.105.0", - "svelte": "^5.56.3", + "@types/node": "^26.0.0", + "@types/vscode": "^1.125.0", + "svelte": "^5.56.4", "typescript": "^6.0.3", - "vite": "^8.0.16", - "vitest": "^4.1.8" + "vite": "^8.1.0", + "vitest": "^4.1.9" }, "contributes": { "commands": [ @@ -1042,7 +1042,7 @@ }, "matterviz.box.box.stroke_color": { "type": "string", - "default": "#000000", + "default": "var(--text-color, black)", "description": "Box outline color" }, "matterviz.box.box.border_radius": { @@ -1061,7 +1061,7 @@ }, "matterviz.box.whisker.color": { "type": "string", - "default": "#000000", + "default": "var(--text-color, black)", "description": "Whisker line color" }, "matterviz.box.whisker.cap_fraction": { @@ -1080,7 +1080,7 @@ }, "matterviz.box.median.color": { "type": "string", - "default": "#000000", + "default": "var(--text-color, black)", "description": "Median line color" }, "matterviz.box.outlier.radius": { diff --git a/package.json b/package.json index 1f940c699..987f90f8f 100644 --- a/package.json +++ b/package.json @@ -186,7 +186,7 @@ }, "dependencies": { "@spglib/moyo-wasm": "^0.12.0", - "@sveltejs/kit": "2.66.0", + "@sveltejs/kit": "2.67.0", "@threlte/core": "^8.5.16", "@threlte/extras": "^9.21.0", "d3-array": "^3.2.4", @@ -202,7 +202,7 @@ "dompurify": "3.4.7", "fflate": "^0.8.3", "h5wasm": "^0.10.3", - "js-yaml": "^4.2.0", + "js-yaml": "^5.1.0", "svelte-multiselect": "^11.7.2", "three": "^0.184.0" }, @@ -222,19 +222,17 @@ "@types/d3-scale-chromatic": "^3.1.0", "@types/d3-shape": "^3.1.8", "@types/d3-time-format": "^4.0.3", - "@types/js-yaml": "^4.0.9", "@types/three": "^0.184.1", - "@typescript/native-preview": "7.0.0-dev.20260618.1", "@vitest/coverage-v8": "4.1.9", "@wooorm/starry-night": "^3.10.0", "happy-dom": "^20.10.6", - "knip": "^6.17.1", + "knip": "^6.18.0", "mdsvex": "^0.12.7", "publint": "^0.3.21", "rehype-katex": "^7.0.1", "remark-math": "3.0.1", - "svelte": "^5.56.3", - "svelte-check-rs": "0.10.1", + "svelte": "^5.56.4", + "svelte-check": "^4.7.1", "typescript": "6.0.3", "vite": "^8.0.16", "vite-plus": "^0.2.1", @@ -270,8 +268,7 @@ "extensions/**" ], "ignoreDependencies": [ - "@typescript/native-preview", - "svelte-check-rs" + "svelte-check" ] } } diff --git a/src/lib/app.css b/src/lib/app.css index 1561995be..44d2283af 100644 --- a/src/lib/app.css +++ b/src/lib/app.css @@ -18,6 +18,13 @@ --sms-selected-bg: light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.15)); --border-radius: 3pt; + /* Single source of truth for viewer control-button icon size, shared by all viewer chromes + (Structure/Brillouin/Fermi/ConvexHull/Trajectory). Scales with the container but is capped so + toolbar icons don't grow oversized in fullscreen. Uses rem (not em) so the size is consistent + across viewers regardless of their local font-size (e.g. Trajectory's controls run at 0.85em). + Override per viewer by setting this on its wrapper. */ + --ctrl-btn-icon-size: clamp(0.7rem, 2cqmin, 0.85rem); + /* App-wide overlay stack: controls < fullscreen/nav < dialogs < floating options. These must exceed Threlte HTML overlays, which use very high z-index values. */ --z-index-overlay-controls: 100000000; diff --git a/src/lib/chempot-diagram/ChemPotDiagram3D.svelte b/src/lib/chempot-diagram/ChemPotDiagram3D.svelte index 28f15dbd8..caa074292 100644 --- a/src/lib/chempot-diagram/ChemPotDiagram3D.svelte +++ b/src/lib/chempot-diagram/ChemPotDiagram3D.svelte @@ -380,6 +380,21 @@ max_energy_per_atom: number | null } type NumericColorMode = Exclude + const domain_annotation_cache = new Map() + + function get_domain_ann_loc(points_3d: number[][]): number[] { + if (points_3d.length >= 3) { + const cache_key = points_3d.map((point) => point.join(`,`)).join(`;`) + const cached = domain_annotation_cache.get(cache_key) + if (cached) return cached + const ann_loc = get_3d_domain_simplexes_and_ann_loc(points_3d).ann_loc + domain_annotation_cache.set(cache_key, ann_loc) + return ann_loc + } + return points_3d[0].map((_, col_idx) => + points_3d.reduce((sum, point) => sum + point[col_idx], 0) / points_3d.length + ) + } const render_domains = $derived.by((): DomainRenderData[] => { if (!diagram_data || plot_elements.length < 2) return [] @@ -407,18 +422,11 @@ ) : pts if (padded.length < 2) continue - const is_draw = formulas_to_draw.includes(formula) - const centroid = padded[0].map((_, col_idx) => - padded.reduce((sum, point) => sum + point[col_idx], 0) / padded.length - ) - const ann_loc = padded.length >= 3 - ? get_3d_domain_simplexes_and_ann_loc(padded).ann_loc - : centroid result.push({ formula, points_3d: padded, - ann_loc, - is_draw_formula: is_draw, + ann_loc: get_domain_ann_loc(padded), + is_draw_formula: formulas_to_draw.includes(formula), label_font_size: bbox_diagonal(padded), }) } @@ -2018,12 +2026,25 @@ hover_info = null } + function stop_phase_pointer_event(raw_event: unknown): void { + if (!raw_event || typeof raw_event !== `object`) return + + const event = raw_event as { nativeEvent?: unknown; stopPropagation?: () => void } + event.stopPropagation?.() + + const native_event = event.nativeEvent + if (!native_event || typeof native_event !== `object`) return + const native_pointer_event = native_event as { stopPropagation?: () => void } + native_pointer_event.stopPropagation?.() + } + function handle_phase_hover(domain_data: HoverMeshData, raw_event: unknown): void { if (locked_hover_formula && locked_hover_formula !== domain_data.formula) return set_hover_info(domain_data, raw_event) } function toggle_phase_lock(domain_data: HoverMeshData, raw_event: unknown): void { + stop_phase_pointer_event(raw_event) if (locked_hover_formula === domain_data.formula) { clear_hover_lock() return diff --git a/src/lib/convex-hull/ConvexHull2D.svelte b/src/lib/convex-hull/ConvexHull2D.svelte index b36202cbf..d3c3a6733 100644 --- a/src/lib/convex-hull/ConvexHull2D.svelte +++ b/src/lib/convex-hull/ConvexHull2D.svelte @@ -738,7 +738,7 @@ color: var(--text-color, currentColor); transition: background-color 0.2s, opacity 0.2s; display: flex; - font-size: clamp(0.85em, 2cqmin, 1.3em); + font-size: var(--ctrl-btn-icon-size, clamp(0.7rem, 2cqmin, 0.85rem)); } :global(.convex-hull-2d :is(.control-btn, .fullscreen-btn):hover) { background-color: color-mix(in srgb, currentColor 8%, transparent); diff --git a/src/lib/convex-hull/ConvexHullChrome.svelte b/src/lib/convex-hull/ConvexHullChrome.svelte index 046089b61..b11dac031 100644 --- a/src/lib/convex-hull/ConvexHullChrome.svelte +++ b/src/lib/convex-hull/ConvexHullChrome.svelte @@ -264,7 +264,7 @@ color: var(--text-color, currentColor); transition: background-color 0.2s; display: flex; - font-size: clamp(0.85em, 2cqmin, 1.3em); + font-size: var(--ctrl-btn-icon-size, clamp(0.7rem, 2cqmin, 0.85rem)); } .control-buttons :global(button):hover { background-color: color-mix(in srgb, currentColor 8%, transparent); diff --git a/src/lib/icons.ts b/src/lib/icons.ts index ed084fafe..3eb521e07 100644 --- a/src/lib/icons.ts +++ b/src/lib/icons.ts @@ -620,6 +620,10 @@ export const ICON_DATA = { viewBox: `0 0 24 24`, path: ``, }, + Grid2x2: { + viewBox: `0 0 24 24`, + path: ``, + }, FermiSurface: { viewBox: `0 0 24 24`, stroke: `currentColor`, diff --git a/src/lib/layout/ViewerChrome.svelte b/src/lib/layout/ViewerChrome.svelte index 1e5540016..9c8674216 100644 --- a/src/lib/layout/ViewerChrome.svelte +++ b/src/lib/layout/ViewerChrome.svelte @@ -99,7 +99,7 @@ display: flex; padding: var(--viewer-buttons-btn-padding, 4px); border-radius: var(--border-radius, 3pt); - font-size: clamp(0.85em, 2cqmin, 1.3em); + font-size: var(--ctrl-btn-icon-size, clamp(0.7rem, 2cqmin, 0.85rem)); } section.control-buttons :global(button:hover) { background-color: color-mix(in srgb, currentColor 8%, transparent); diff --git a/src/lib/plot/bar/BarPlot.svelte b/src/lib/plot/bar/BarPlot.svelte index e6fcaca69..59da4f1d2 100644 --- a/src/lib/plot/bar/BarPlot.svelte +++ b/src/lib/plot/bar/BarPlot.svelte @@ -33,11 +33,13 @@ compute_element_placement, PlotAxis, PlotLegend, + PlotMarginals, ReferenceLine, ScatterPoint, } from '$lib/plot' - import type { AxisChangeState } from '$lib/plot/core/axis-utils' - import { create_axis_loader } from '$lib/plot/core/axis-utils' + import type { MarginalSeriesInput, MarginalsProp } from '$lib/plot/core/marginals' + import { add_sides, marginal_axis, marginal_axis_presence, normalize_marginals, reserve_marginal_pad } from '$lib/plot/core/marginals' + import { type AxisChangeState, create_axis_loader } from '$lib/plot/core/axis-utils' import { create_placed_tween } from '$lib/plot/core/placed-tween.svelte' import { create_pan_zoom } from '$lib/plot/core/pan-zoom.svelte' import { create_legend_visibility } from '$lib/plot/core/utils/series-visibility' @@ -49,13 +51,13 @@ import type { IndexedRefLine } from '$lib/plot/core/reference-line' import { group_ref_lines_by_z, index_ref_lines } from '$lib/plot/core/reference-line' import { + create_axis_scales, create_color_scale, - create_scale, create_size_scale, generate_ticks, get_tick_label, } from '$lib/plot/core/scales' - import { DEFAULT_MARKERS } from '$lib/plot/core/types' + import { DEFAULT_MARKERS, SCALE_DEFAULTS } from '$lib/plot/core/types' import { DEFAULTS } from '$lib/settings' import { extent } from 'd3-array' import type { Snippet } from 'svelte' @@ -72,6 +74,7 @@ } from '$lib/plot/core/auto-place' import { calc_auto_padding, + DEFAULT_PLOT_PADDING, filter_padding, LABEL_GAP_DEFAULT, y2_axis_label_x, @@ -109,7 +112,7 @@ y2_axis: y2_axis_prop = $bindable({}), display = $bindable(DEFAULTS.bar.display), range_padding = 0.05, - padding = { t: 20, b: 60, l: 60, r: 20 }, + padding = DEFAULT_PLOT_PADDING, legend = {}, show_legend, bar = {}, @@ -121,12 +124,8 @@ on_bar_click, on_bar_hover, // Line marker props (matching ScatterPlot) - color_scale = { - type: `linear`, - scheme: `interpolateViridis`, - value_range: undefined, - }, - size_scale = { type: `linear`, radius_range: [2, 10], value_range: undefined }, + color_scale = SCALE_DEFAULTS.color, + size_scale = SCALE_DEFAULTS.size, point_tween, on_point_click, on_point_hover, @@ -146,6 +145,7 @@ on_axis_change, on_error, pan = {}, + marginals = false, ...rest }: HTMLAttributes & BasePlotProps & PlotConfig & { series?: BarSeries[] @@ -200,6 +200,7 @@ ) => void on_error?: (error: AxisLoadError) => void pan?: PanConfig + marginals?: MarginalsProp } = $props() // Initialize bar, line, y2_axis with defaults - using $derived for reactivity @@ -277,6 +278,11 @@ let x2_series = $derived( visible_series.filter((srs) => srs.x_axis === `x2`), ) + // Whether the secondary x2 (top) / y2 (right) axis actually renders: BarPlot only supports + // them in vertical orientation. Derive once so ticks, padding, axis rendering, and marginal + // placement stay in sync. (Data-existence checks below use the bare `*_series.length` instead.) + let show_x2 = $derived(x2_series.length > 0 && orientation === `vertical`) + let show_y2 = $derived(y2_series.length > 0 && orientation === `vertical`) let auto_ranges = $derived(compute_bar_auto_ranges({ visible_series, @@ -323,26 +329,22 @@ }) // Layout: dynamic padding based on tick label widths - const default_padding = { t: 20, b: 60, l: 60, r: 20 } // base_pad reserves space for tick labels/axis titles; pad (below) adds decoration reservations - let base_pad = $derived(filter_padding(padding, default_padding)) + let base_pad = $derived(filter_padding(padding, DEFAULT_PLOT_PADDING)) // Update padding when format or ticks change $effect(() => { const new_pad = width && height && ticks.y.length > 0 ? calc_auto_padding({ padding, - default_padding, + default_padding: DEFAULT_PLOT_PADDING, x2_axis: { ...x2_axis, tick_values: ticks.x2 }, y_axis: { ...y_axis, tick_values: ticks.y }, y2_axis: { ...y2_axis, tick_values: ticks.y2 }, }) - : filter_padding(padding, default_padding) + : filter_padding(padding, DEFAULT_PLOT_PADDING) // Expand right padding if y2 ticks are shown (only for vertical orientation) - if ( - width && height && y2_series.length > 0 && ticks.y2.length > 0 && - orientation === `vertical` - ) { + if (width && height && show_y2 && ticks.y2.length > 0) { // Need space for: tick shift + tick width + gap (30px) + label space (20px if present) // When ticks are inside, they don't contribute to padding const inside = y2_axis.tick?.label?.inside ?? false @@ -355,10 +357,7 @@ ) } // Expand top padding if x2 ticks are shown (only for vertical orientation) - if ( - width && height && x2_series.length > 0 && ticks.x2.length > 0 && - orientation === `vertical` - ) { + if (width && height && show_x2 && ticks.x2.length > 0) { const inside = x2_axis.tick?.label?.inside ?? false const tick_shift = inside ? 0 : Math.abs(x2_axis.tick?.label?.shift?.y ?? 0) + 5 const tick_height = inside ? 0 : 16 @@ -426,32 +425,47 @@ : null, }) ) - const pad = $derived(decor.pad) + // Resolve marginals: a cumulative/Pareto CDF over the CATEGORY axis weighted by bar height. + // Categories sit on x (vertical) or y (horizontal), so the default side and the value array + // flip with orientation. The value axis carries no marginal (a bar's height isn't a sample). + const marginal_is_vertical = $derived(orientation === `vertical`) + const resolved_marginals = $derived( + normalize_marginals( + marginals, + marginal_is_vertical ? { top: { type: `cdf` } } : { right: { type: `cdf` } }, + ), + ) + const pad = $derived(add_sides(decor.pad, reserve_marginal_pad(resolved_marginals))) + const marginal_series = $derived( + internal_series.map((srs) => ({ + x: marginal_is_vertical ? (srs?.x ?? []) : undefined, + y: marginal_is_vertical ? undefined : (srs?.x ?? []), + // magnitude weights so negative bars still yield a monotonic cumulative (CDF) marginal + weight: srs?.y?.map((value) => Math.abs(value)) ?? [], + color: srs?.color ?? + (srs?.render_mode === `line` ? line_state.color : bar_state.color) ?? `steelblue`, + label: srs?.label, + visible: srs?.visible ?? true, + x_axis: srs?.x_axis, + y_axis: srs?.y_axis, + })), + ) + const marginal_has_axis = $derived(marginal_axis_presence(show_x2, show_y2)) const legend_auto_outside = $derived(decor.legend_outside) const legend_outside_x = $derived(decor.legend_pos.x) const legend_outside_y = $derived(decor.legend_pos.y) const chart_width = $derived(Math.max(1, width - pad.l - pad.r)) const chart_height = $derived(Math.max(1, height - pad.t - pad.b)) - // Scales - let scales = $derived({ - x: create_scale(x_axis.scale_type ?? `linear`, ranges.current.x, [ - pad.l, - width - pad.r, - ]), - x2: create_scale(x2_axis.scale_type ?? `linear`, ranges.current.x2, [ - pad.l, - width - pad.r, - ]), - y: create_scale(y_axis.scale_type ?? `linear`, ranges.current.y, [ - height - pad.b, - pad.t, - ]), - y2: create_scale(y2_axis.scale_type ?? `linear`, ranges.current.y2, [ - height - pad.b, - pad.t, - ]), - }) + let scales = $derived( + create_axis_scales( + { x: x_axis, x2: x2_axis, y: y_axis, y2: y2_axis }, + ranges.current, + pad, + width, + height, + ), + ) // Compute plot center for point tweening origin let plot_center_x = $derived(pad.l + (width - pad.r - pad.l) / 2) @@ -505,52 +519,32 @@ !Array.isArray(user_ticks) ) return user_ticks return Object.fromEntries( - category_list.map((cat, idx) => [idx, cat]), - ) as Record + category_list.map((cat, idx): [number, string] => [idx, cat]), + ) }) - // Ticks - let ticks = $derived({ - x: width && height - ? (category_indices && cat_axis === `x` ? cat_tick_indices : generate_ticks( - ranges.current.x, - x_axis.scale_type ?? `linear`, - x_axis.ticks, - scales.x, - { default_count: 8 }, - )) - : [], - y: width && height - ? (category_indices && cat_axis === `y` ? cat_tick_indices : generate_ticks( - ranges.current.y, - y_axis.scale_type ?? `linear`, - y_axis.ticks, - scales.y, - { default_count: 6 }, - )) - : [], - y2: width && height && y2_series.length > 0 && orientation === `vertical` - ? generate_ticks( - ranges.current.y2, - y2_axis.scale_type ?? `linear`, - y2_axis.ticks, - scales.y2, - { - default_count: 6, - }, - ) - : [], - x2: width && height && x2_series.length > 0 && orientation === `vertical` - ? generate_ticks( - ranges.current.x2, - x2_axis.scale_type ?? `linear`, - x2_axis.ticks, - scales.x2, - { - default_count: 8, - }, - ) - : [], + let ticks = $derived.by(() => { + const axis_ticks = ( + axis: typeof x_axis, + range: Vec2, + scale: typeof scales.x, + default_count: number, + show = true, + ) => + width && height && show + ? generate_ticks(range, axis.scale_type ?? `linear`, axis.ticks, scale, { default_count }) + : [] + // categorical axes show one tick per category instead of generated numeric ticks + return { + x: category_indices && cat_axis === `x` && width && height + ? cat_tick_indices + : axis_ticks(x_axis, ranges.current.x, scales.x, 8), + y: category_indices && cat_axis === `y` && width && height + ? cat_tick_indices + : axis_ticks(y_axis, ranges.current.y, scales.y, 6), + y2: axis_ticks(y2_axis, ranges.current.y2, scales.y2, 6, show_y2), + x2: axis_ticks(x2_axis, ranges.current.x2, scales.x2, 8, show_x2), + } }) // Cache measured tick-label widths so expensive canvas text measurement @@ -574,13 +568,13 @@ const next_x = invert_rect_range(scales.x, start.x, current.x) if (!next_x) return x_axis = { ...x_axis, range: next_x } - // gate x2/y2 on series presence: their scales are [0, 1] sentinels otherwise, - // so inverting would store a phantom range in the bindable prop - const next_x2 = x2_series.length > 0 ? invert_rect_range(scales.x2, start.x, current.x) : null + // gate x2/y2 on whether they actually render (show_x2/show_y2 also require vertical); + // otherwise their [0, 1] sentinel scales would store a phantom range in the bindable prop + const next_x2 = show_x2 ? invert_rect_range(scales.x2, start.x, current.x) : null if (next_x2) x2_axis_prop = { ...x2_axis_prop, range: next_x2 } const next_y = invert_rect_range(scales.y, start.y, current.y) if (next_y) y_axis = { ...y_axis, range: next_y } - const next_y2 = y2_series.length > 0 ? invert_rect_range(scales.y2, start.y, current.y) : null + const next_y2 = show_y2 ? invert_rect_range(scales.y2, start.y, current.y) : null if (next_y2) y2_axis_prop = { ...y2_axis_prop, range: next_y2 } }, on_reset: () => { @@ -805,24 +799,17 @@ }) // State accessors for shared axis change handler + // Secondary axes read the merged $derived (x2_axis/y2_axis) but write the raw $bindable props + // (x2_axis_prop/y2_axis_prop) so library defaults aren't pushed into the parent's bound state const axis_state: AxisChangeState> = { - get_axis: (axis) => { - if (axis === `x`) return x_axis - if (axis === `x2`) return x2_axis - if (axis === `y`) return y_axis - return y2_axis - }, - set_axis: (axis, config) => { - // Spread into existing state to preserve merged type structure - if (axis === `x`) x_axis = { ...x_axis, ...config } - else if (axis === `x2`) x2_axis_prop = { ...x2_axis_prop, ...config } - else if (axis === `y`) y_axis = { ...y_axis, ...config } - else y2_axis_prop = { ...y2_axis_prop, ...config } + axes: { + x: { get: () => x_axis, set: (config) => (x_axis = { ...x_axis, ...config }) }, + x2: { get: () => x2_axis, set: (config) => (x2_axis_prop = { ...x2_axis_prop, ...config }) }, + y: { get: () => y_axis, set: (config) => (y_axis = { ...y_axis, ...config }) }, + y2: { get: () => y2_axis, set: (config) => (y2_axis_prop = { ...y2_axis_prop, ...config }) }, }, - get_series: () => series, - set_series: (new_series) => (series = new_series), - get_loading: () => axis_loading, - set_loading: (axis) => (axis_loading = axis), + series: { get: () => series, set: (next) => (series = next) }, + loading: { get: () => axis_loading, set: (axis) => (axis_loading = axis) }, } // Shared handler + one-shot auto-load bound to this component's state @@ -937,10 +924,10 @@ - {#if x2_series.length > 0 && orientation === `vertical`} + {#if show_x2} - {#if y2_series.length > 0 && orientation === `vertical`} + {#if show_y2} 0} - has_y2={y2_series.length > 0} + has_x2={show_x2} + has_y2={show_y2} {width} {height} {pad} @@ -1334,6 +1321,23 @@ {@render ref_lines_layer(ref_lines_by_z.below_points)} {@render ref_lines_layer(ref_lines_by_z.above_all)} + + + category_list[Math.round(pos)] : undefined), + x2: marginal_axis(scales.x2, ranges.current.x2, x2_axis), + y1: marginal_axis(scales.y, ranges.current.y, y_axis, cat_axis === `y` ? (pos) => category_list[Math.round(pos)] : undefined), + y2: marginal_axis(scales.y2, ranges.current.y2, y2_axis), + }} + id={clip_path_id} + /> @@ -1424,12 +1428,12 @@ bind:y_axis bind:y2_axis={y2_axis_prop} bind:display - auto_x_range={auto_ranges.x as Vec2} - auto_x2_range={auto_ranges.x2 as Vec2} - auto_y_range={auto_ranges.y as Vec2} - auto_y2_range={auto_ranges.y2 as Vec2} - has_x2_points={x2_series.length > 0} - has_y2_points={y2_series.length > 0} + auto_x_range={auto_ranges.x} + auto_x2_range={auto_ranges.x2} + auto_y_range={auto_ranges.y} + auto_y2_range={auto_ranges.y2} + has_x2_points={show_x2} + has_y2_points={show_y2} children={controls_extra} /> {/if} diff --git a/src/lib/plot/bar/data.ts b/src/lib/plot/bar/data.ts index 2202140ac..d1c259e24 100644 --- a/src/lib/plot/bar/data.ts +++ b/src/lib/plot/bar/data.ts @@ -3,6 +3,7 @@ // offsets and grouped-bar layout info. Extracted from BarPlot.svelte so the // math is unit-testable without mounting the component. +import type { Vec2 } from '$lib/math' import { get_nice_data_range } from '$lib/plot/core/scales' import type { BarMode, BarSeries, Orientation, ScaleType } from '$lib/plot/core/types' import { get_scale_type_name } from '$lib/plot/core/types' @@ -99,14 +100,14 @@ export interface BarAutoRangeOpts> { // values share one sign and no explicit range is set. export function compute_bar_auto_ranges>( opts: BarAutoRangeOpts, -): { x: number[]; x2: number[]; y: number[]; y2: number[] } { +): { x: Vec2; x2: Vec2; y: Vec2; y2: Vec2 } { const { visible_series, mode, orientation, range_padding, category_count } = opts const calc_y_range = ( series_list: readonly NumericBarSeries[], y_limit: [number | null, number | null], scale_type: ScaleType, - ) => { + ): Vec2 => { let points = series_list.flatMap((srs) => srs.x.map((x_val, idx) => ({ x: x_val, y: srs.y[idx] })), ) @@ -173,14 +174,14 @@ export function compute_bar_auto_ranges>( limit: [number | null, number | null], scale_type: ScaleType, is_time: boolean, - ) => { + ): Vec2 => { const points = series_list.flatMap((srs) => srs.x.map((x_val) => ({ x: x_val, y: 0 }))) if (points.length === 0) return [0, 1] return get_nice_data_range(points, (pt) => pt.x, limit, scale_type, range_padding, is_time) } // Categorical x axes use a fixed range centered on integer indices - const x_auto_range = + const x_auto_range: Vec2 = category_count > 0 ? [-0.5, category_count - 0.5] : calc_x_range( @@ -189,12 +190,8 @@ export function compute_bar_auto_ranges>( opts.x_scale_type, opts.x_is_time, ) - const x2_auto_range = calc_x_range( - opts.x2_series, - opts.x2_range, - opts.x2_scale_type, - opts.x2_is_time, - ) + const { x2_series, x2_range, x2_scale_type, x2_is_time } = opts + const x2_auto_range = calc_x_range(x2_series, x2_range, x2_scale_type, x2_is_time) const y1_range = calc_y_range(opts.y1_series, opts.y_range, opts.y_scale_type) const y2_auto_range = calc_y_range(opts.y2_series, opts.y2_range, opts.y2_scale_type) diff --git a/src/lib/plot/box/BoxPlot.svelte b/src/lib/plot/box/BoxPlot.svelte index 7be7c711e..581df1f9f 100644 --- a/src/lib/plot/box/BoxPlot.svelte +++ b/src/lib/plot/box/BoxPlot.svelte @@ -28,8 +28,11 @@ compute_element_placement, PlotAxis, PlotLegend, + PlotMarginals, ReferenceLine, } from '$lib/plot' + import type { MarginalSeriesInput, MarginalsProp } from '$lib/plot/core/marginals' + import { add_sides, marginal_axis, marginal_axis_presence, normalize_marginals, reserve_marginal_pad } from '$lib/plot/core/marginals' import { build_obstacles_norm, clip_bar, @@ -50,6 +53,7 @@ } from '$lib/plot/core/interactions' import { calc_auto_padding, + DEFAULT_PLOT_PADDING, filter_padding, LABEL_GAP_DEFAULT, y2_axis_label_x, @@ -59,7 +63,7 @@ import type { IndexedRefLine } from '$lib/plot/core/reference-line' import { group_ref_lines_by_z, index_ref_lines } from '$lib/plot/core/reference-line' import { - create_scale, + create_axis_scales, generate_ticks, get_nice_data_range, get_tick_label, @@ -115,7 +119,7 @@ y2_axis: y2_axis_prop = $bindable({}), display = $bindable(DEFAULTS.box.display), range_padding = 0.05, - padding = { t: 20, b: 60, l: 60, r: 20 }, + padding = DEFAULT_PLOT_PADDING, legend = {}, show_legend, box = {}, @@ -158,6 +162,7 @@ header_controls, controls_extra, pan = {}, + marginals = false, ...rest }: HTMLAttributes & BasePlotProps & PlotConfig & { series?: BoxPlotSeries[] @@ -202,6 +207,7 @@ on_ref_line_click?: (event: RefLineEvent) => void on_ref_line_hover?: (event: RefLineEvent | null) => void pan?: PanConfig + marginals?: MarginalsProp } = $props() let box_state = $derived({ ...DEFAULTS.box.box, ...box }) @@ -362,6 +368,11 @@ orientation === `vertical` ? srs.y_axis === `y2` : srs.x_axis === `x2` let secondary_boxes = $derived(visible_boxes.filter((box_item) => is_secondary(box_item.series))) let has_secondary = $derived(secondary_boxes.length > 0) + // The secondary value axis renders transposed by orientation: x2 (top) when horizontal, y2 + // (right) when vertical. Derive once so axis rendering, ticks, range writes, point picking, and + // marginal placement all stay provably in sync. + let show_x2 = $derived(has_secondary && orientation === `horizontal`) + let show_y2 = $derived(has_secondary && orientation === `vertical`) // Collect value-axis points (whiskers, quartiles, outliers, KDE tails) for auto-range const value_points = (boxes: Box[]): { x: number; y: number }[] => @@ -432,22 +443,19 @@ } }) - const default_padding = { t: 20, b: 60, l: 60, r: 20 } - let base_pad = $derived(filter_padding(padding, default_padding)) + let base_pad = $derived(filter_padding(padding, DEFAULT_PLOT_PADDING)) $effect(() => { // dynamic padding from tick label widths const new_pad = width && height && ticks.y.length > 0 ? calc_auto_padding({ padding, - default_padding, + default_padding: DEFAULT_PLOT_PADDING, x2_axis: { ...x2_axis, tick_values: ticks.x2 }, y_axis: { ...y_axis, tick_values: ticks.y }, y2_axis: { ...y2_axis, tick_values: ticks.y2 }, }) - : filter_padding(padding, default_padding) - if ( - width && height && orientation === `vertical` && has_secondary && ticks.y2.length > 0 - ) { + : filter_padding(padding, DEFAULT_PLOT_PADDING) + if (width && height && show_y2 && ticks.y2.length > 0) { const inside = y2_axis.tick?.label?.inside ?? false const tick_shift = inside ? 0 : (y2_axis.tick?.label?.shift?.x ?? 0) + 8 const tick_width_contribution = inside ? 0 : tick_label_widths.y2_max @@ -505,19 +513,45 @@ : null, }) ) - const pad = $derived(decor.pad) + // Marginals are opt-in (default prop `false`) and bind to the VALUE axis, pooling each box's + // raw samples. The default side follows orientation (value axis = y when vertical, x when + // horizontal) so the `marginals` boolean / type-string shorthand land on a meaningful side. + // Each box is tagged with its value axis so a primary-axis marginal ignores secondary boxes. + const marginal_vertical = $derived(orientation === `vertical`) + const resolved_marginals = $derived( + normalize_marginals(marginals, marginal_vertical ? { right: true } : { top: true }), + ) + const pad = $derived(add_sides(decor.pad, reserve_marginal_pad(resolved_marginals))) + const marginal_series = $derived( + visible_boxes.map((box_item) => { + const secondary = is_secondary(box_item.series) + return { + x: marginal_vertical ? undefined : (box_item.series.y ?? []), + y: marginal_vertical ? (box_item.series.y ?? []) : undefined, + color: box_color(box_item.idx), + label: box_item.series.label, + visible: true, + x_axis: marginal_vertical ? `x1` : (secondary ? `x2` : `x1`), + y_axis: marginal_vertical ? (secondary ? `y2` : `y1`) : `y1`, + } + }), + ) + const marginal_has_axis = $derived(marginal_axis_presence(show_x2, show_y2)) const legend_auto_outside = $derived(decor.legend_outside) const legend_outside_x = $derived(decor.legend_pos.x) const legend_outside_y = $derived(decor.legend_pos.y) const chart_width = $derived(Math.max(1, width - pad.l - pad.r)) const chart_height = $derived(Math.max(1, height - pad.t - pad.b)) - let scales = $derived({ - x: create_scale(x_axis.scale_type ?? `linear`, ranges.current.x, [pad.l, width - pad.r]), - x2: create_scale(x2_axis.scale_type ?? `linear`, ranges.current.x2, [pad.l, width - pad.r]), - y: create_scale(y_axis.scale_type ?? `linear`, ranges.current.y, [height - pad.b, pad.t]), - y2: create_scale(y2_axis.scale_type ?? `linear`, ranges.current.y2, [height - pad.b, pad.t]), - }) + let scales = $derived( + create_axis_scales( + { x: x_axis, x2: x2_axis, y: y_axis, y2: y2_axis }, + ranges.current, + pad, + width, + height, + ), + ) // Value scale for a box (vertical -> y/y2, horizontal -> x/x2), made log-safe: on a // log value axis, stats at values <= 0 (whisker_low is often exactly 0; negative @@ -543,35 +577,28 @@ return Object.fromEntries(slot_list.map((cat, idx) => [idx, cat])) }) - let ticks = $derived({ - x: width && height - ? (cat_axis === `x` ? slot_indices : generate_ticks( - ranges.current.x, - x_axis.scale_type ?? `linear`, - x_axis.ticks, - scales.x, - { default_count: 8 }, - )) - : [], - y: width && height - ? (cat_axis === `y` ? slot_indices : generate_ticks( - ranges.current.y, - y_axis.scale_type ?? `linear`, - y_axis.ticks, - scales.y, - { default_count: 6 }, - )) - : [], - y2: width && height && has_secondary && orientation === `vertical` - ? generate_ticks(ranges.current.y2, y2_axis.scale_type ?? `linear`, y2_axis.ticks, scales.y2, { - default_count: 6, - }) - : [], - x2: width && height && has_secondary && orientation === `horizontal` - ? generate_ticks(ranges.current.x2, x2_axis.scale_type ?? `linear`, x2_axis.ticks, scales.x2, { - default_count: 8, - }) - : [], + let ticks = $derived.by(() => { + const axis_ticks = ( + axis: typeof x_axis, + range: Vec2, + scale: typeof scales.x, + default_count: number, + show = true, + ) => + width && height && show + ? generate_ticks(range, axis.scale_type ?? `linear`, axis.ticks, scale, { default_count }) + : [] + // categorical axes show one tick per slot instead of generated numeric ticks + return { + x: cat_axis === `x` && width && height + ? slot_indices + : axis_ticks(x_axis, ranges.current.x, scales.x, 8), + y: cat_axis === `y` && width && height + ? slot_indices + : axis_ticks(y_axis, ranges.current.y, scales.y, 6), + y2: axis_ticks(y2_axis, ranges.current.y2, scales.y2, 6, show_y2), + x2: axis_ticks(x2_axis, ranges.current.x2, scales.x2, 8, show_x2), + } }) let tick_label_widths = $derived({ @@ -595,15 +622,11 @@ // the secondary value axis is x2 only in horizontal mode, y2 only in vertical // (is_secondary keys off orientation); writing the off-orientation axis would // store a phantom range from its [0, 1] sentinel scale into the bound prop - const next_x2 = has_secondary && orientation === `horizontal` - ? invert_rect_range(scales.x2, start.x, current.x) - : null + const next_x2 = show_x2 ? invert_rect_range(scales.x2, start.x, current.x) : null if (next_x2) x2_axis_prop = { ...x2_axis_prop, range: next_x2 } const next_y = invert_rect_range(scales.y, start.y, current.y) if (next_y) y_axis = { ...y_axis, range: next_y } - const next_y2 = has_secondary && orientation === `vertical` - ? invert_rect_range(scales.y2, start.y, current.y) - : null + const next_y2 = show_y2 ? invert_rect_range(scales.y2, start.y, current.y) : null if (next_y2) y2_axis_prop = { ...y2_axis_prop, range: next_y2 } }, on_reset: () => { @@ -847,10 +870,10 @@ - {#if has_secondary && orientation === `horizontal`} + {#if show_x2} - {#if has_secondary && orientation === `vertical`} + {#if show_y2} + {#if legend && should_show_legend} @@ -1206,12 +1246,12 @@ bind:y_axis bind:y2_axis={y2_axis_prop} bind:display - auto_x_range={auto_ranges.x as Vec2} - auto_x2_range={auto_ranges.x2 as Vec2} - auto_y_range={auto_ranges.y as Vec2} - auto_y2_range={auto_ranges.y2 as Vec2} - has_x2_points={has_secondary && orientation === `horizontal`} - has_y2_points={has_secondary && orientation === `vertical`} + auto_x_range={auto_ranges.x} + auto_x2_range={auto_ranges.x2} + auto_y_range={auto_ranges.y} + auto_y2_range={auto_ranges.y2} + has_x2_points={show_x2} + has_y2_points={show_y2} children={controls_extra} /> {/if} diff --git a/src/lib/plot/core/axis-utils.ts b/src/lib/plot/core/axis-utils.ts index 8e0d6dc63..11857f17c 100644 --- a/src/lib/plot/core/axis-utils.ts +++ b/src/lib/plot/core/axis-utils.ts @@ -6,7 +6,6 @@ import type { AxisLoadError, BarSeries, DataLoaderFn, - DataLoaderResult, DataSeries, } from '$lib/plot/core/types' @@ -56,95 +55,76 @@ export function merge_series_state( }) } -// State accessors for axis change handler - enables sharing logic across plot components +// Read/write accessors a plot supplies so the shared axis-change logic can drive its reactive +// state. Each axis has independent get/set so a plot may read a merged $derived but write the raw +// $bindable prop (e.g. BarPlot's secondary axes) without pushing library defaults into bound state. export interface AxisChangeState { - get_axis: (axis: AxisType) => AxisConfig - set_axis: (axis: AxisType, config: AxisConfig) => void - get_series: () => T[] - set_series: (series: T[]) => void - get_loading: () => AxisType | null - set_loading: (axis: AxisType | null) => void + axes: Record AxisConfig; set: (config: AxisConfig) => void }> + series: { get: () => T[]; set: (series: T[]) => void } + loading: { get: () => AxisType | null; set: (axis: AxisType | null) => void } } -// Handle axis property change - loads new data via data_loader -// Returns a function bound to the component's state accessors -export const create_axis_change_handler = - ( - state: AxisChangeState, - data_loader: DataLoaderFn, T> | undefined, - on_axis_change?: (axis: AxisType, key: string, new_series: T[]) => void, - on_error?: (error: AxisLoadError) => void, - ): ((axis: AxisType, key: string) => Promise) => - async (axis: AxisType, key: string) => { - if (!data_loader || state.get_loading()) return +// Axis-change loader: `handle_axis_change` loads new data for an axis property change via the +// data_loader (reading `get_props` fresh each call so callbacks stay current), and `try_auto_load` +// loads the first axis option once when series start empty. Reads state through getters so a +// component `$effect(try_auto_load)` tracks them reactively. +export function create_axis_loader( + state: AxisChangeState, + get_props: () => { + data_loader?: DataLoaderFn, T> + on_axis_change?: (axis: AxisType, key: string, new_series: T[]) => void + on_error?: (error: AxisLoadError) => void + }, +) { + let auto_load_attempted = false // prevent infinite retries on failure - const axis_config = state.get_axis(axis) + // No-op when already loading, or when the key is unchanged and series are already present + const handle_axis_change = async (axis: AxisType, key: string) => { + const { data_loader, on_axis_change, on_error } = get_props() + if (!data_loader || state.loading.get()) return + const axis_config = state.axes[axis].get() const prev_key = axis_config.selected_key + if (prev_key === key && state.series.get().length > 0) return - // Skip if key unchanged AND series already loaded (allows initial load when series empty) - if (prev_key === key && state.get_series().length > 0) return - - // Update selected_key immediately for UI feedback - state.set_axis(axis, { ...axis_config, selected_key: key }) - state.set_loading(axis) - + state.axes[axis].set({ ...axis_config, selected_key: key }) // immediate UI feedback + state.loading.set(axis) + let merged: T[] = state.series.get() + let load_error: AxisLoadError | undefined try { - const result: DataLoaderResult, T> = await data_loader( - axis, - key, - state.get_series(), - ) - - // Merge new series with preserved state from old series - const merged = merge_series_state(state.get_series(), result.series) - state.set_series(merged) - - // Update axis label/unit if provided - if (result.axis_label || result.axis_unit) { - const current = state.get_axis(axis) - state.set_axis(axis, { + const result = await data_loader(axis, key, state.series.get()) + merged = merge_series_state(state.series.get(), result.series) + state.series.set(merged) + // !== undefined (not truthiness) so a loader can intentionally clear a label/unit to `` + if (result.axis_label !== undefined || result.axis_unit !== undefined) { + const current = state.axes[axis].get() + state.axes[axis].set({ ...current, label: result.axis_label ?? current.label, unit: result.axis_unit ?? current.unit, }) } - - on_axis_change?.(axis, key, merged) } catch (err) { console.error(`Failed to load data for ${axis}=${key}:`, err) - - // Revert selection - state.set_axis(axis, { ...state.get_axis(axis), selected_key: prev_key }) - - const message = to_error(err).message - on_error?.({ axis, key, message }) + state.axes[axis].set(axis_config) // revert every axis field mutated above, not just selected_key + load_error = { axis, key, message: to_error(err).message } } finally { - state.set_loading(null) + state.loading.set(null) // always clears, even if a consumer callback below throws + } + // callbacks run only after internal state is fully settled, so a throwing consumer callback + // can neither leave `loading` stuck nor trigger the loader rollback + if (load_error) { + on_error?.(load_error) + return } + on_axis_change?.(axis, key, merged) } -// Bundle axis change handler with one-shot auto-load of the first axis option. -// get_props keeps callback props fresh; try_auto_load reads series/axis configs -// through state getters so a component `$effect(try_auto_load)` tracks them reactively. -export function create_axis_loader( - state: AxisChangeState, - get_props: () => { - data_loader?: DataLoaderFn, T> - on_axis_change?: (axis: AxisType, key: string, new_series: T[]) => void - on_error?: (error: AxisLoadError) => void - }, -) { - let auto_load_attempted = false // prevent infinite retries on failure - const handle_axis_change = (axis: AxisType, key: string) => { - const { data_loader, on_axis_change, on_error } = get_props() - return create_axis_change_handler(state, data_loader, on_axis_change, on_error)(axis, key) - } - // Auto-load data if series is empty but options exist (runs once, x-axis first then y) + // Auto-load once if series is empty but options exist (x-axis first, then y) const try_auto_load = () => { - if (state.get_series().length > 0 || !get_props().data_loader || auto_load_attempted) + if (state.series.get().length > 0 || !get_props().data_loader || auto_load_attempted) return for (const axis of [`x`, `y`] as const) { - const config = state.get_axis(axis) + const config = state.axes[axis].get() if (config.options?.length) { auto_load_attempted = true handle_axis_change(axis, config.selected_key ?? config.options[0].key).catch(() => {}) diff --git a/src/lib/plot/core/components/AxisLabel.svelte b/src/lib/plot/core/components/AxisLabel.svelte index 54802ad67..4515e399e 100644 --- a/src/lib/plot/core/components/AxisLabel.svelte +++ b/src/lib/plot/core/components/AxisLabel.svelte @@ -14,6 +14,7 @@ loading = false, axis_type, on_select, + width = AXIS_LABEL_CONTAINER.width, }: { x: number y: number @@ -25,13 +26,15 @@ loading?: boolean axis_type: `x` | `x2` | `y` | `y2` on_select?: (key: string) => void + // container width for centering/wrapping; wider lets long horizontal titles fit on one line + width?: number } = $props() - import { luminance } from '$lib/colors' + import { get_d3_interpolator, luminance } from '$lib/colors' import Spinner from '$lib/feedback/Spinner.svelte' import { format_num } from '$lib/labels' import { sanitize_html } from '$lib/sanitize' @@ -20,11 +20,15 @@ Orientation, ScaleType, } from '$lib/plot/core/types' - import { get_arcsinh_threshold, get_scale_type_name } from '$lib/plot/core/types' + import { + get_arcsinh_threshold, + get_scale_type_name, + SCALE_DEFAULTS, + } from '$lib/plot/core/types' let { title = $bindable(), - color_scale = $bindable(`interpolateViridis`), + color_scale = $bindable(SCALE_DEFAULTS.scheme), bar_style = undefined, title_style = undefined, wrapper_style = undefined, @@ -261,7 +265,7 @@ if (color_scale_fn) return color_scale_fn // Prioritize passed function // Fallback: create function from scheme name/function in 'color_scale' prop - let interpolator = d3_sc.interpolateViridis // Default interpolator + let interpolator = get_d3_interpolator(SCALE_DEFAULTS.scheme) // Default interpolator if (typeof color_scale === `string`) { const func_name = color_scale.startsWith(`interpolate`) ? color_scale diff --git a/src/lib/plot/core/components/Line.svelte b/src/lib/plot/core/components/Line.svelte index e3cfd5822..a32e79f05 100644 --- a/src/lib/plot/core/components/Line.svelte +++ b/src/lib/plot/core/components/Line.svelte @@ -1,19 +1,11 @@ + +{#each side_renders as render (render.side)} + {@const { side, config, rect, clip_id, ctx, curves, value_axis } = render} + + + + + + + {#if config.snippet} + {@render config.snippet(ctx)} + {:else} + {#each curves as curve, curve_idx (curve_idx)} + + {#if curve.kind === `bars`} + {#each curve.bars as bar, bar_idx (bar_idx)} + + {/each} + {:else if curve.kind === `rug`} + {#each curve.rug as mark, mark_idx (mark_idx)} + + {/each} + {:else} + {#if curve.area_path} + + {/if} + {#if curve.line_path} + + {/if} + {/if} + {/each} + {/if} + + + {#if value_axis} + + + {#each value_axis.ticks as tick, tick_idx (tick_idx)} + {tick.text} + {/each} + {#if value_axis.title.text} + {value_axis.title.text} + {/if} + + {/if} + + {#if is_hoverable(config)} + + { + event.stopPropagation() + hovered = on_marginal_move(event, ctx) + }} + onpointerleave={() => (hovered = null)} + onmousemove={stop} + onpointerdown={stop} + onmousedown={stop} + /> + {/if} +{/each} + + +{#if side_renders.some((render) => is_hoverable(render.config))} + +
+ {#if hovered && tip} + + {#if tip.snippet} + {@render tip.snippet(hovered)} + {:else} + + {#if tip.label}{tip.label}
{/if}{@html tip.head_label}: {tip.head_value}{#if tip.value}
{tip.value}{/if} + {/if} +
+ {/if} +
+
+{/if} + + diff --git a/src/lib/plot/core/components/index.ts b/src/lib/plot/core/components/index.ts index 1b5810f08..7326d2337 100644 --- a/src/lib/plot/core/components/index.ts +++ b/src/lib/plot/core/components/index.ts @@ -8,6 +8,7 @@ export { default as Line } from './Line.svelte' export { default as PlotAxis } from './PlotAxis.svelte' export { default as PlotControls } from './PlotControls.svelte' export { default as PlotLegend } from './PlotLegend.svelte' +export { default as PlotMarginals } from './PlotMarginals.svelte' export { default as PlotTooltip } from './PlotTooltip.svelte' export { default as PortalSelect } from './PortalSelect.svelte' export { default as ReferenceLine } from './ReferenceLine.svelte' diff --git a/src/lib/plot/core/data-cleaning-signal.ts b/src/lib/plot/core/data-cleaning-signal.ts new file mode 100644 index 000000000..a967b4870 --- /dev/null +++ b/src/lib/plot/core/data-cleaning-signal.ts @@ -0,0 +1,629 @@ +// Signal-processing primitives for plot data cleaning: rolling variance, instability/oscillation +// detection, smoothing (moving average, Savitzky-Golay) and local (MAD-based) outlier removal. +// Pure numeric helpers with no plot/series dependencies; orchestration lives in ./data-cleaning. + +// Oscillation detection weights (all default to 1.0) +export interface OscillationWeights { + derivative_variance?: number // Weight for derivative variance method + amplitude_growth?: number // Weight for exponential amplitude growth + sign_changes?: number // Weight for derivative sign change frequency +} + +// Smoothing algorithm configuration (discriminated union for type safety) +export type SmoothingConfig = + | { type: `moving_avg`; window: number } + | { type: `savgol`; window: number; polynomial_order?: number } // window must be odd + | { type: `gaussian`; sigma: number } // sigma controls Gaussian kernel width + +// Local outlier detection config (sliding window approach) +export interface LocalOutlierConfig { + window_half?: number // Points on each side for local context (default: 7) + mad_threshold?: number // MADs from local median to flag outlier (default: 2.0) + max_iterations?: number // Iterative passes to catch clustered outliers (default: 5) +} + +// Result of local outlier detection +export interface LocalOutlierResult { + kept_indices: number[] + removed_indices: number[] + iterations_used: number +} + +// Instability detection result +export interface InstabilityResult { + detected: boolean + onset_index: number + onset_x: number + combined_score: number + method_scores: { + derivative_variance: number + amplitude_growth: number + sign_changes: number + } +} + +// Default configuration values +const DEFAULT_WINDOW_SIZE = 5 +const DEFAULT_OSCILLATION_THRESHOLD = 3.0 +const DEFAULT_POLYNOMIAL_ORDER = 2 + +// --- Core Detection Functions --- + +// Compute rolling variance using Welford's online algorithm - O(n) +// Returns variance for each point based on surrounding window +export function compute_local_variance(values: number[], window_size: number): number[] { + const len = values.length + if (len === 0) return [] + if (len === 1) return [0] + + const half_window = Math.floor(window_size / 2) + const result: number[] = Array(len) + + // Single pass for each index, no slice allocation (avoids O(n × window) allocations) + for (let idx = 0; idx < len; idx++) { + const start = Math.max(0, idx - half_window) + const end = Math.min(len, idx + half_window + 1) + + // Welford's online variance calculation + let [mean, m2, count] = [0, 0, 0] + for (let jdx = start; jdx < end; jdx++) { + const val = values[jdx] + if (!Number.isFinite(val)) continue + count++ + const delta = val - mean + mean += delta / count + const delta2 = val - mean + m2 += delta * delta2 + } + + result[idx] = count > 1 ? m2 / (count - 1) : 0 + } + + return result +} + +// Compute first-order finite differences (discrete derivative) +function compute_derivatives(values: number[]): number[] { + if (values.length < 2) return [] + const derivs: number[] = Array(values.length - 1) + for (let idx = 0; idx < values.length - 1; idx++) { + derivs[idx] = values[idx + 1] - values[idx] + } + return derivs +} + +// Detect derivative variance spikes (method 1) +// Returns onset index where derivative variance exceeds threshold +function detect_derivative_variance( + values: number[], + window_size: number, + threshold_multiplier: number, +): { onset_index: number; score: number } { + const derivs = compute_derivatives(values) + if (derivs.length < window_size) { + return { onset_index: -1, score: 0 } + } + + const local_var = compute_local_variance(derivs, window_size) + + // Compute baseline variance from first stable portion + const baseline_end = Math.min(Math.floor(derivs.length / 4), 50) + const baseline_vars = local_var.slice(0, Math.max(baseline_end, window_size)) + const baseline_median = median(baseline_vars.filter((val) => val > 0)) + + if (baseline_median === 0) { + return { onset_index: -1, score: 0 } + } + + // Find first point where variance exceeds threshold + let max_score = 0 + for (let idx = baseline_end; idx < local_var.length; idx++) { + const ratio = local_var[idx] / baseline_median + if (ratio > max_score) max_score = ratio + if (ratio > threshold_multiplier) { + return { onset_index: idx + 1, score: ratio } // +1 to convert from deriv index to value index + } + } + + return { onset_index: -1, score: max_score } +} + +// Detect exponential amplitude growth (method 2) +// Looks for values growing exponentially from their mean +function detect_amplitude_growth( + values: number[], + window_size: number, +): { onset_index: number; score: number } { + if (values.length < window_size * 3) { + return { onset_index: -1, score: 0 } + } + + // Compute running amplitude (deviation from local mean) + const amplitudes: number[] = [] + const half_window = Math.floor(window_size / 2) + + for (let idx = half_window; idx < values.length - half_window; idx++) { + const start = idx - half_window + const end = idx + half_window + 1 + const window_len = end - start + + // Compute local mean without slice allocation + let sum = 0 + for (let jdx = start; jdx < end; jdx++) sum += values[jdx] + const local_mean = sum / window_len + + // Find max deviation without slice allocation + let max_deviation = 0 + for (let jdx = start; jdx < end; jdx++) { + const deviation = Math.abs(values[jdx] - local_mean) + if (deviation > max_deviation) max_deviation = deviation + } + amplitudes.push(max_deviation) + } + + if (amplitudes.length < 10) { + return { onset_index: -1, score: 0 } + } + + // Detect exponential growth by checking if amplitude ratio increases + const baseline_amp = median(amplitudes.slice(0, Math.floor(amplitudes.length / 4))) + if (baseline_amp === 0) { + return { onset_index: -1, score: 0 } + } + + let max_score = 0 + for (let idx = Math.floor(amplitudes.length / 4); idx < amplitudes.length; idx++) { + const ratio = amplitudes[idx] / baseline_amp + if (ratio > max_score) max_score = ratio + + // Exponential growth threshold: amplitude 10x baseline + if (ratio > 10) { + return { onset_index: idx + half_window, score: ratio / 10 } + } + } + + return { onset_index: -1, score: max_score / 10 } +} + +// Count derivative sign changes per window (method 3) +// Detects high-frequency oscillations +function detect_sign_change_frequency( + values: number[], + window_size: number, + max_changes_per_window: number = 3, +): { onset_index: number; score: number } { + const derivs = compute_derivatives(values) + if (derivs.length < window_size * 2) { + return { onset_index: -1, score: 0 } + } + + // Count sign changes in sliding windows + const half_window = Math.floor(window_size / 2) + let max_score = 0 + + for (let idx = half_window; idx < derivs.length - half_window; idx++) { + const start = idx - half_window + const end = idx + half_window + 1 + let sign_changes = 0 + + // Count sign changes without slice allocation + for (let jdx = start + 1; jdx < end; jdx++) { + if (derivs[jdx] * derivs[jdx - 1] < 0) { + sign_changes++ + } + } + + const normalized_score = sign_changes / max_changes_per_window + if (normalized_score > max_score) max_score = normalized_score + + if (sign_changes > max_changes_per_window) { + return { onset_index: idx + 1, score: normalized_score } + } + } + + return { onset_index: -1, score: max_score } +} + +// Combined detection with configurable weights +export function detect_instability( + x_values: readonly number[], + y_values: readonly number[], + config: { + oscillation_weights?: OscillationWeights + oscillation_threshold?: number + window_size?: number + } = {}, +): InstabilityResult { + const window_size = config.window_size ?? DEFAULT_WINDOW_SIZE + const threshold = config.oscillation_threshold ?? DEFAULT_OSCILLATION_THRESHOLD + const weights = { + derivative_variance: config.oscillation_weights?.derivative_variance ?? 1.0, + amplitude_growth: config.oscillation_weights?.amplitude_growth ?? 1.0, + sign_changes: config.oscillation_weights?.sign_changes ?? 1.0, + } + + // Filter out invalid values for detection + const valid_indices: number[] = [] + const valid_y: number[] = [] + for (let idx = 0; idx < y_values.length; idx++) { + if (Number.isFinite(y_values[idx])) { + valid_indices.push(idx) + valid_y.push(y_values[idx]) + } + } + + if (valid_y.length < window_size * 2) { + const method_scores = { derivative_variance: 0, amplitude_growth: 0, sign_changes: 0 } + const detected = false + return { detected, onset_index: -1, onset_x: NaN, combined_score: 0, method_scores } + } + + // Run all three detection methods + const deriv_result = detect_derivative_variance(valid_y, window_size, threshold) + const amp_result = detect_amplitude_growth(valid_y, window_size) + const sign_result = detect_sign_change_frequency(valid_y, window_size) + + const method_scores = { + derivative_variance: deriv_result.score, + amplitude_growth: amp_result.score, + sign_changes: sign_result.score, + } + + // Compute weighted combined score + const total_weight = + weights.derivative_variance + weights.amplitude_growth + weights.sign_changes + const combined_score = + total_weight > 0 + ? (weights.derivative_variance * deriv_result.score + + weights.amplitude_growth * amp_result.score + + weights.sign_changes * sign_result.score) / + total_weight + : 0 + + // Find earliest onset across all methods that exceeded threshold + const onset_candidates = [ + { + idx: deriv_result.onset_index, + score: deriv_result.score * weights.derivative_variance, + }, + { idx: amp_result.onset_index, score: amp_result.score * weights.amplitude_growth }, + { idx: sign_result.onset_index, score: sign_result.score * weights.sign_changes }, + ].filter((candidate) => candidate.idx >= 0) + + let onset_index = -1 + if (onset_candidates.length > 0) { + // Take earliest detected onset + onset_index = Math.min(...onset_candidates.map((candidate) => candidate.idx)) + // Map back to original index if we filtered values + if (onset_index >= 0 && onset_index < valid_indices.length) { + onset_index = valid_indices[onset_index] + } + } + + const detected = combined_score >= threshold || onset_index >= 0 + const onset_x = + onset_index >= 0 && onset_index < x_values.length ? x_values[onset_index] : NaN + + return { detected, onset_index, onset_x, combined_score, method_scores } +} + +// --- Smoothing Functions --- + +// Moving average - O(n) +export function smooth_moving_average(values: number[], window: number): number[] { + if (values.length === 0 || window <= 1) return [...values] + + const result: number[] = Array(values.length) + const half_window = Math.floor(window / 2) + + for (let idx = 0; idx < values.length; idx++) { + const start = Math.max(0, idx - half_window) + const end = Math.min(values.length, idx + half_window + 1) + let [sum, count] = [0, 0] + + for (let jdx = start; jdx < end; jdx++) { + if (Number.isFinite(values[jdx])) { + sum += values[jdx] + count++ + } + } + + result[idx] = count > 0 ? sum / count : values[idx] + } + + return result +} + +// Savitzky-Golay filter coefficients for polynomial smoothing +// Uses least-squares polynomial fitting in each window +function compute_savgol_coefficients(window: number, order: number): number[] { + // Ensure window is odd + const half = Math.floor(window / 2) + const size = 2 * half + 1 + + // Build Vandermonde matrix for polynomial fitting + const vandermonde: number[][] = [] + for (let idx = -half; idx <= half; idx++) { + const row: number[] = [] + for (let power = 0; power <= order; power++) { + row.push(idx ** power) + } + vandermonde.push(row) + } + + // Compute (V^T V)^-1 V^T using simple pseudoinverse + // For smoothing, we only need the first row (constant term coefficients) + const vtv = multiply_matrices(transpose(vandermonde), vandermonde) + const vtv_inv = invert_matrix(vtv) + if (!vtv_inv) { + // Fallback to uniform weights + return Array(size).fill(1 / size) + } + + const vt = transpose(vandermonde) + const coeffs_matrix = multiply_matrices(vtv_inv, vt) + + // First row gives smoothing coefficients + return coeffs_matrix[0] +} + +// Savitzky-Golay filter (derivative-preserving) - O(n * window) +export function smooth_savitzky_golay( + values: number[], + window: number, + polynomial_order: number = DEFAULT_POLYNOMIAL_ORDER, +): number[] { + if (values.length === 0) return [] + + // Ensure window is odd and >= polynomial_order + 1 + let actual_window = window % 2 === 0 ? window + 1 : window + actual_window = Math.max(actual_window, polynomial_order + 2) + actual_window = Math.min(actual_window, values.length) + // max/min clamps above can re-even the window (polynomial_order + 2 is even for even orders; + // values.length may be even). Force odd so the kernel stays symmetric and matches + // compute_savgol_coefficients' (2 * floor(window / 2) + 1)-length row. + if (actual_window % 2 === 0) actual_window -= 1 + + if (actual_window < 3) return [...values] + + const coeffs = compute_savgol_coefficients(actual_window, polynomial_order) + const half = Math.floor(actual_window / 2) + const result: number[] = Array(values.length) + // Cache coefficient sum to avoid O(n × window) redundant reductions in loop + const coeffs_sum = coeffs.reduce((sum, coeff) => sum + coeff, 0) + + for (let idx = 0; idx < values.length; idx++) { + let [sum, weight_sum] = [0, 0] + + for (let jdx = 0; jdx < actual_window; jdx++) { + const data_idx = idx - half + jdx + if (data_idx >= 0 && data_idx < values.length && Number.isFinite(values[data_idx])) { + sum += coeffs[jdx] * values[data_idx] + weight_sum += coeffs[jdx] + } + } + + result[idx] = weight_sum !== 0 ? (sum / weight_sum) * coeffs_sum : values[idx] + } + + return result +} + +// --- Local Outlier Detection --- + +// Default values for local outlier detection +const DEFAULT_LOCAL_WINDOW_HALF = 7 +const DEFAULT_LOCAL_MAD_THRESHOLD = 2.0 +const DEFAULT_LOCAL_MAX_ITERATIONS = 5 + +// Compute local median within a window, excluding the center point +// This allows detecting if the center point is an outlier relative to its neighbors +function compute_local_median_excluding_center( + values: number[], + center_idx: number, + window_half: number, +): number { + const window_values: number[] = [] + const start = Math.max(0, center_idx - window_half) + const end = Math.min(values.length - 1, center_idx + window_half) + + for (let idx = start; idx <= end; idx++) { + if (idx !== center_idx && Number.isFinite(values[idx])) { + window_values.push(values[idx]) + } + } + + if (window_values.length === 0) return values[center_idx] + return median(window_values) +} + +// Compute local MAD (median absolute deviation) within a window, excluding center +function compute_local_mad_excluding_center( + values: number[], + center_idx: number, + window_half: number, + local_median: number, +): number { + const abs_devs: number[] = [] + const start = Math.max(0, center_idx - window_half) + const end = Math.min(values.length - 1, center_idx + window_half) + + for (let idx = start; idx <= end; idx++) { + if (idx !== center_idx && Number.isFinite(values[idx])) { + abs_devs.push(Math.abs(values[idx] - local_median)) + } + } + + if (abs_devs.length === 0) return 0 + return median(abs_devs) +} + +// Remove local outliers using iterative sliding window MAD-based detection +// Detects points that deviate significantly from their local neighborhood +// Returns only the indices to keep, preserving good data before AND after bad regions +export function remove_local_outliers( + y_values: readonly number[], + config: LocalOutlierConfig = {}, +): LocalOutlierResult { + const window_half = config.window_half ?? DEFAULT_LOCAL_WINDOW_HALF + const mad_threshold = config.mad_threshold ?? DEFAULT_LOCAL_MAD_THRESHOLD + const max_iterations = config.max_iterations ?? DEFAULT_LOCAL_MAX_ITERATIONS + + const len = y_values.length + if (len === 0) { + return { kept_indices: [], removed_indices: [], iterations_used: 0 } + } + + // Need enough neighbors for meaningful local statistics + const min_points_needed = window_half * 2 + 1 + if (len < min_points_needed) { + return { + kept_indices: Array.from({ length: len }, (_, idx) => idx), + removed_indices: [], + iterations_used: 0, + } + } + + let kept_mask = Array(len).fill(true) + let iterations_used = 0 + + for (let iter = 0; iter < max_iterations; iter++) { + let removed_any = false + const new_kept_mask = [...kept_mask] + // Note: Local statistics are computed from original values, not filtered values. + // This prevents cascading removals where one outlier's removal dramatically + // shifts statistics and causes false positives on neighboring points. + for (let idx = 0; idx < len; idx++) { + if (!kept_mask[idx]) continue // Already removed + if (!Number.isFinite(y_values[idx])) continue // Skip invalid values + + const local_median = compute_local_median_excluding_center( + y_values as number[], + idx, + window_half, + ) + const local_mad = compute_local_mad_excluding_center( + y_values as number[], + idx, + window_half, + local_median, + ) + + // Cannot compute robust threshold if MAD is zero (all neighbors identical) + if (local_mad === 0) continue + + const threshold = local_mad * mad_threshold + const deviation = Math.abs(y_values[idx] - local_median) + + if (deviation > threshold) { + new_kept_mask[idx] = false + removed_any = true + } + } + + kept_mask = new_kept_mask + iterations_used = iter + 1 + + if (!removed_any) break + } + + const kept_indices: number[] = [] + const removed_indices: number[] = [] + + for (let idx = 0; idx < len; idx++) { + if (kept_mask[idx]) { + kept_indices.push(idx) + } else { + removed_indices.push(idx) + } + } + + return { kept_indices, removed_indices, iterations_used } +} + +// --- Utility Functions --- + +function median(values: number[]): number { + if (values.length === 0) return 0 + const sorted = [...values].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2 +} + +// Simple matrix operations for Savitzky-Golay +function transpose(matrix: number[][]): number[][] { + if (matrix.length === 0) return [] + const rows = matrix.length + const cols = matrix[0].length + const result: number[][] = Array.from({ length: cols }, () => Array(rows).fill(0)) + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + result[col][row] = matrix[row][col] + } + } + return result +} + +function multiply_matrices(matrix_a: number[][], matrix_b: number[][]): number[][] { + const rows_a = matrix_a.length + const cols_a = matrix_a[0]?.length ?? 0 + const cols_b = matrix_b[0]?.length ?? 0 + + const result: number[][] = Array.from({ length: rows_a }, () => Array(cols_b).fill(0)) + + for (let row = 0; row < rows_a; row++) { + for (let col = 0; col < cols_b; col++) { + for (let idx_k = 0; idx_k < cols_a; idx_k++) { + result[row][col] += matrix_a[row][idx_k] * matrix_b[idx_k][col] + } + } + } + + return result +} + +function invert_matrix(matrix: number[][]): number[][] | null { + const size = matrix.length + if (size === 0 || matrix[0].length !== size) return null + + // Create augmented matrix [A | I] + const aug: number[][] = matrix.map((row, idx) => [ + ...row, + ...Array.from({ length: size }, (_, jdx) => (idx === jdx ? 1 : 0)), + ]) + + // Gaussian elimination with partial pivoting + for (let col = 0; col < size; col++) { + // Find pivot + let max_row = col + for (let row = col + 1; row < size; row++) { + if (Math.abs(aug[row][col]) > Math.abs(aug[max_row][col])) { + max_row = row + } + } + + if (Math.abs(aug[max_row][col]) < 1e-10) { + return null // Singular + } // Swap rows + + ;[aug[col], aug[max_row]] = [aug[max_row], aug[col]] + + // Eliminate column + const pivot = aug[col][col] + for (let jdx = 0; jdx < 2 * size; jdx++) { + aug[col][jdx] /= pivot + } + + for (let row = 0; row < size; row++) { + if (row !== col) { + const factor = aug[row][col] + for (let jdx = 0; jdx < 2 * size; jdx++) { + aug[row][jdx] -= factor * aug[col][jdx] + } + } + } + } + + // Extract inverse + return aug.map((row) => row.slice(size)) +} diff --git a/src/lib/plot/core/data-cleaning.ts b/src/lib/plot/core/data-cleaning.ts index dd0cec7d0..58a90e52c 100644 --- a/src/lib/plot/core/data-cleaning.ts +++ b/src/lib/plot/core/data-cleaning.ts @@ -1,19 +1,25 @@ // Data cleaning utilities for plot data -// Detects oscillations, enforces physical bounds, and handles multi-dimensional datasets +// Enforces physical bounds, handles invalid values, and cleans multi-dimensional datasets. +// Signal-processing primitives (variance, instability detection, smoothing, outlier removal) +// live in ./data-cleaning-signal and are re-exported here so the public API is unchanged. import type { Vec2 } from '$lib/math' import type { DataSeries } from '$lib/plot/core/types' import { apply_gaussian_smearing } from '$lib/spectral/helpers' +import { + detect_instability, + type LocalOutlierConfig, + type OscillationWeights, + remove_local_outliers, + type SmoothingConfig, + smooth_moving_average, + smooth_savitzky_golay, +} from '$lib/plot/core/data-cleaning-signal' + +export * from '$lib/plot/core/data-cleaning-signal' // --- Data Cleaning Types --- -// Oscillation detection weights (all default to 1.0) -export interface OscillationWeights { - derivative_variance?: number // Weight for derivative variance method - amplitude_growth?: number // Weight for exponential amplitude growth - sign_changes?: number // Weight for derivative sign change frequency -} - // How to handle invalid values (NaN, Infinity) export type InvalidValueMode = `remove` | `propagate` | `interpolate` @@ -27,26 +33,6 @@ export interface PhysicalBounds { mode?: `clamp` | `filter` | `null` // How to handle violations } -// Smoothing algorithm configuration (discriminated union for type safety) -export type SmoothingConfig = - | { type: `moving_avg`; window: number } - | { type: `savgol`; window: number; polynomial_order?: number } // window must be odd - | { type: `gaussian`; sigma: number } // sigma controls Gaussian kernel width - -// Local outlier detection config (sliding window approach) -export interface LocalOutlierConfig { - window_half?: number // Points on each side for local context (default: 7) - mad_threshold?: number // MADs from local median to flag outlier (default: 2.0) - max_iterations?: number // Iterative passes to catch clustered outliers (default: 5) -} - -// Result of local outlier detection -export interface LocalOutlierResult { - kept_indices: number[] - removed_indices: number[] - iterations_used: number -} - // Main cleaning configuration export interface CleaningConfig { // Oscillation detection @@ -85,381 +71,6 @@ export interface CleaningResult { quality: CleaningQuality } -// Instability detection result -export interface InstabilityResult { - detected: boolean - onset_index: number - onset_x: number - combined_score: number - method_scores: { - derivative_variance: number - amplitude_growth: number - sign_changes: number - } -} - -// Default configuration values -const DEFAULT_WINDOW_SIZE = 5 -const DEFAULT_OSCILLATION_THRESHOLD = 3.0 -const DEFAULT_POLYNOMIAL_ORDER = 2 - -// --- Core Detection Functions --- - -// Compute rolling variance using Welford's online algorithm - O(n) -// Returns variance for each point based on surrounding window -export function compute_local_variance(values: number[], window_size: number): number[] { - const len = values.length - if (len === 0) return [] - if (len === 1) return [0] - - const half_window = Math.floor(window_size / 2) - const result: number[] = Array(len) - - // Single pass for each index, no slice allocation (avoids O(n × window) allocations) - for (let idx = 0; idx < len; idx++) { - const start = Math.max(0, idx - half_window) - const end = Math.min(len, idx + half_window + 1) - - // Welford's online variance calculation - let [mean, m2, count] = [0, 0, 0] - for (let jdx = start; jdx < end; jdx++) { - const val = values[jdx] - if (!Number.isFinite(val)) continue - count++ - const delta = val - mean - mean += delta / count - const delta2 = val - mean - m2 += delta * delta2 - } - - result[idx] = count > 1 ? m2 / (count - 1) : 0 - } - - return result -} - -// Compute first-order finite differences (discrete derivative) -function compute_derivatives(values: number[]): number[] { - if (values.length < 2) return [] - const derivs: number[] = Array(values.length - 1) - for (let idx = 0; idx < values.length - 1; idx++) { - derivs[idx] = values[idx + 1] - values[idx] - } - return derivs -} - -// Detect derivative variance spikes (method 1) -// Returns onset index where derivative variance exceeds threshold -function detect_derivative_variance( - values: number[], - window_size: number, - threshold_multiplier: number, -): { onset_index: number; score: number } { - const derivs = compute_derivatives(values) - if (derivs.length < window_size) { - return { onset_index: -1, score: 0 } - } - - const local_var = compute_local_variance(derivs, window_size) - - // Compute baseline variance from first stable portion - const baseline_end = Math.min(Math.floor(derivs.length / 4), 50) - const baseline_vars = local_var.slice(0, Math.max(baseline_end, window_size)) - const baseline_median = median(baseline_vars.filter((val) => val > 0)) - - if (baseline_median === 0) { - return { onset_index: -1, score: 0 } - } - - // Find first point where variance exceeds threshold - let max_score = 0 - for (let idx = baseline_end; idx < local_var.length; idx++) { - const ratio = local_var[idx] / baseline_median - if (ratio > max_score) max_score = ratio - if (ratio > threshold_multiplier) { - return { onset_index: idx + 1, score: ratio } // +1 to convert from deriv index to value index - } - } - - return { onset_index: -1, score: max_score } -} - -// Detect exponential amplitude growth (method 2) -// Looks for values growing exponentially from their mean -function detect_amplitude_growth( - values: number[], - window_size: number, -): { onset_index: number; score: number } { - if (values.length < window_size * 3) { - return { onset_index: -1, score: 0 } - } - - // Compute running amplitude (deviation from local mean) - const amplitudes: number[] = [] - const half_window = Math.floor(window_size / 2) - - for (let idx = half_window; idx < values.length - half_window; idx++) { - const start = idx - half_window - const end = idx + half_window + 1 - const window_len = end - start - - // Compute local mean without slice allocation - let sum = 0 - for (let jdx = start; jdx < end; jdx++) sum += values[jdx] - const local_mean = sum / window_len - - // Find max deviation without slice allocation - let max_deviation = 0 - for (let jdx = start; jdx < end; jdx++) { - const deviation = Math.abs(values[jdx] - local_mean) - if (deviation > max_deviation) max_deviation = deviation - } - amplitudes.push(max_deviation) - } - - if (amplitudes.length < 10) { - return { onset_index: -1, score: 0 } - } - - // Detect exponential growth by checking if amplitude ratio increases - const baseline_amp = median(amplitudes.slice(0, Math.floor(amplitudes.length / 4))) - if (baseline_amp === 0) { - return { onset_index: -1, score: 0 } - } - - let max_score = 0 - for (let idx = Math.floor(amplitudes.length / 4); idx < amplitudes.length; idx++) { - const ratio = amplitudes[idx] / baseline_amp - if (ratio > max_score) max_score = ratio - - // Exponential growth threshold: amplitude 10x baseline - if (ratio > 10) { - return { onset_index: idx + half_window, score: ratio / 10 } - } - } - - return { onset_index: -1, score: max_score / 10 } -} - -// Count derivative sign changes per window (method 3) -// Detects high-frequency oscillations -function detect_sign_change_frequency( - values: number[], - window_size: number, - max_changes_per_window: number = 3, -): { onset_index: number; score: number } { - const derivs = compute_derivatives(values) - if (derivs.length < window_size * 2) { - return { onset_index: -1, score: 0 } - } - - // Count sign changes in sliding windows - const half_window = Math.floor(window_size / 2) - let max_score = 0 - - for (let idx = half_window; idx < derivs.length - half_window; idx++) { - const start = idx - half_window - const end = idx + half_window + 1 - let sign_changes = 0 - - // Count sign changes without slice allocation - for (let jdx = start + 1; jdx < end; jdx++) { - if (derivs[jdx] * derivs[jdx - 1] < 0) { - sign_changes++ - } - } - - const normalized_score = sign_changes / max_changes_per_window - if (normalized_score > max_score) max_score = normalized_score - - if (sign_changes > max_changes_per_window) { - return { onset_index: idx + 1, score: normalized_score } - } - } - - return { onset_index: -1, score: max_score } -} - -// Combined detection with configurable weights -export function detect_instability( - x_values: readonly number[], - y_values: readonly number[], - config: Pick< - CleaningConfig, - `oscillation_weights` | `oscillation_threshold` | `window_size` - > = {}, -): InstabilityResult { - const window_size = config.window_size ?? DEFAULT_WINDOW_SIZE - const threshold = config.oscillation_threshold ?? DEFAULT_OSCILLATION_THRESHOLD - const weights = { - derivative_variance: config.oscillation_weights?.derivative_variance ?? 1.0, - amplitude_growth: config.oscillation_weights?.amplitude_growth ?? 1.0, - sign_changes: config.oscillation_weights?.sign_changes ?? 1.0, - } - - // Filter out invalid values for detection - const valid_indices: number[] = [] - const valid_y: number[] = [] - for (let idx = 0; idx < y_values.length; idx++) { - if (Number.isFinite(y_values[idx])) { - valid_indices.push(idx) - valid_y.push(y_values[idx]) - } - } - - if (valid_y.length < window_size * 2) { - const method_scores = { derivative_variance: 0, amplitude_growth: 0, sign_changes: 0 } - const detected = false - return { detected, onset_index: -1, onset_x: NaN, combined_score: 0, method_scores } - } - - // Run all three detection methods - const deriv_result = detect_derivative_variance(valid_y, window_size, threshold) - const amp_result = detect_amplitude_growth(valid_y, window_size) - const sign_result = detect_sign_change_frequency(valid_y, window_size) - - const method_scores = { - derivative_variance: deriv_result.score, - amplitude_growth: amp_result.score, - sign_changes: sign_result.score, - } - - // Compute weighted combined score - const total_weight = - weights.derivative_variance + weights.amplitude_growth + weights.sign_changes - const combined_score = - total_weight > 0 - ? (weights.derivative_variance * deriv_result.score + - weights.amplitude_growth * amp_result.score + - weights.sign_changes * sign_result.score) / - total_weight - : 0 - - // Find earliest onset across all methods that exceeded threshold - const onset_candidates = [ - { - idx: deriv_result.onset_index, - score: deriv_result.score * weights.derivative_variance, - }, - { idx: amp_result.onset_index, score: amp_result.score * weights.amplitude_growth }, - { idx: sign_result.onset_index, score: sign_result.score * weights.sign_changes }, - ].filter((candidate) => candidate.idx >= 0) - - let onset_index = -1 - if (onset_candidates.length > 0) { - // Take earliest detected onset - onset_index = Math.min(...onset_candidates.map((candidate) => candidate.idx)) - // Map back to original index if we filtered values - if (onset_index >= 0 && onset_index < valid_indices.length) { - onset_index = valid_indices[onset_index] - } - } - - const detected = combined_score >= threshold || onset_index >= 0 - const onset_x = - onset_index >= 0 && onset_index < x_values.length ? x_values[onset_index] : NaN - - return { detected, onset_index, onset_x, combined_score, method_scores } -} - -// --- Smoothing Functions --- - -// Moving average - O(n) -export function smooth_moving_average(values: number[], window: number): number[] { - if (values.length === 0 || window <= 1) return [...values] - - const result: number[] = Array(values.length) - const half_window = Math.floor(window / 2) - - for (let idx = 0; idx < values.length; idx++) { - const start = Math.max(0, idx - half_window) - const end = Math.min(values.length, idx + half_window + 1) - let [sum, count] = [0, 0] - - for (let jdx = start; jdx < end; jdx++) { - if (Number.isFinite(values[jdx])) { - sum += values[jdx] - count++ - } - } - - result[idx] = count > 0 ? sum / count : values[idx] - } - - return result -} - -// Savitzky-Golay filter coefficients for polynomial smoothing -// Uses least-squares polynomial fitting in each window -function compute_savgol_coefficients(window: number, order: number): number[] { - // Ensure window is odd - const half = Math.floor(window / 2) - const size = 2 * half + 1 - - // Build Vandermonde matrix for polynomial fitting - const vandermonde: number[][] = [] - for (let idx = -half; idx <= half; idx++) { - const row: number[] = [] - for (let power = 0; power <= order; power++) { - row.push(idx ** power) - } - vandermonde.push(row) - } - - // Compute (V^T V)^-1 V^T using simple pseudoinverse - // For smoothing, we only need the first row (constant term coefficients) - const vtv = multiply_matrices(transpose(vandermonde), vandermonde) - const vtv_inv = invert_matrix(vtv) - if (!vtv_inv) { - // Fallback to uniform weights - return Array(size).fill(1 / size) - } - - const vt = transpose(vandermonde) - const coeffs_matrix = multiply_matrices(vtv_inv, vt) - - // First row gives smoothing coefficients - return coeffs_matrix[0] -} - -// Savitzky-Golay filter (derivative-preserving) - O(n * window) -export function smooth_savitzky_golay( - values: number[], - window: number, - polynomial_order: number = DEFAULT_POLYNOMIAL_ORDER, -): number[] { - if (values.length === 0) return [] - - // Ensure window is odd and >= polynomial_order + 1 - let actual_window = window % 2 === 0 ? window + 1 : window - actual_window = Math.max(actual_window, polynomial_order + 2) - actual_window = Math.min(actual_window, values.length) - - if (actual_window < 3) return [...values] - - const coeffs = compute_savgol_coefficients(actual_window, polynomial_order) - const half = Math.floor(actual_window / 2) - const result: number[] = Array(values.length) - // Cache coefficient sum to avoid O(n × window) redundant reductions in loop - const coeffs_sum = coeffs.reduce((sum, coeff) => sum + coeff, 0) - - for (let idx = 0; idx < values.length; idx++) { - let [sum, weight_sum] = [0, 0] - - for (let jdx = 0; jdx < actual_window; jdx++) { - const data_idx = idx - half + jdx - if (data_idx >= 0 && data_idx < values.length && Number.isFinite(values[data_idx])) { - sum += coeffs[jdx] * values[data_idx] - weight_sum += coeffs[jdx] - } - } - - result[idx] = weight_sum !== 0 ? (sum / weight_sum) * coeffs_sum : values[idx] - } - - return result -} - // Apply smoothing based on config function apply_smoothing( x_values: number[], @@ -469,149 +80,15 @@ function apply_smoothing( if (config.type === `moving_avg`) { return smooth_moving_average(y_values, config.window) } else if (config.type === `savgol`) { - return smooth_savitzky_golay( - y_values, - config.window, - config.polynomial_order ?? DEFAULT_POLYNOMIAL_ORDER, - ) + // pass polynomial_order through: when it's undefined, smooth_savitzky_golay's + // default parameter applies (JS defaults also kick in for an explicit undefined) + return smooth_savitzky_golay(y_values, config.window, config.polynomial_order) } else if (config.type === `gaussian`) { return apply_gaussian_smearing(x_values, y_values, config.sigma) } return y_values } -// --- Local Outlier Detection --- - -// Default values for local outlier detection -const DEFAULT_LOCAL_WINDOW_HALF = 7 -const DEFAULT_LOCAL_MAD_THRESHOLD = 2.0 -const DEFAULT_LOCAL_MAX_ITERATIONS = 5 - -// Compute local median within a window, excluding the center point -// This allows detecting if the center point is an outlier relative to its neighbors -function compute_local_median_excluding_center( - values: number[], - center_idx: number, - window_half: number, -): number { - const window_values: number[] = [] - const start = Math.max(0, center_idx - window_half) - const end = Math.min(values.length - 1, center_idx + window_half) - - for (let idx = start; idx <= end; idx++) { - if (idx !== center_idx && Number.isFinite(values[idx])) { - window_values.push(values[idx]) - } - } - - if (window_values.length === 0) return values[center_idx] - return median(window_values) -} - -// Compute local MAD (median absolute deviation) within a window, excluding center -function compute_local_mad_excluding_center( - values: number[], - center_idx: number, - window_half: number, - local_median: number, -): number { - const abs_devs: number[] = [] - const start = Math.max(0, center_idx - window_half) - const end = Math.min(values.length - 1, center_idx + window_half) - - for (let idx = start; idx <= end; idx++) { - if (idx !== center_idx && Number.isFinite(values[idx])) { - abs_devs.push(Math.abs(values[idx] - local_median)) - } - } - - if (abs_devs.length === 0) return 0 - return median(abs_devs) -} - -// Remove local outliers using iterative sliding window MAD-based detection -// Detects points that deviate significantly from their local neighborhood -// Returns only the indices to keep, preserving good data before AND after bad regions -export function remove_local_outliers( - y_values: readonly number[], - config: LocalOutlierConfig = {}, -): LocalOutlierResult { - const window_half = config.window_half ?? DEFAULT_LOCAL_WINDOW_HALF - const mad_threshold = config.mad_threshold ?? DEFAULT_LOCAL_MAD_THRESHOLD - const max_iterations = config.max_iterations ?? DEFAULT_LOCAL_MAX_ITERATIONS - - const len = y_values.length - if (len === 0) { - return { kept_indices: [], removed_indices: [], iterations_used: 0 } - } - - // Need enough neighbors for meaningful local statistics - const min_points_needed = window_half * 2 + 1 - if (len < min_points_needed) { - return { - kept_indices: Array.from({ length: len }, (_, idx) => idx), - removed_indices: [], - iterations_used: 0, - } - } - - let kept_mask = Array(len).fill(true) - let iterations_used = 0 - - for (let iter = 0; iter < max_iterations; iter++) { - let removed_any = false - const new_kept_mask = [...kept_mask] - // Note: Local statistics are computed from original values, not filtered values. - // This prevents cascading removals where one outlier's removal dramatically - // shifts statistics and causes false positives on neighboring points. - for (let idx = 0; idx < len; idx++) { - if (!kept_mask[idx]) continue // Already removed - if (!Number.isFinite(y_values[idx])) continue // Skip invalid values - - const local_median = compute_local_median_excluding_center( - y_values as number[], - idx, - window_half, - ) - const local_mad = compute_local_mad_excluding_center( - y_values as number[], - idx, - window_half, - local_median, - ) - - // Cannot compute robust threshold if MAD is zero (all neighbors identical) - if (local_mad === 0) continue - - const threshold = local_mad * mad_threshold - const deviation = Math.abs(y_values[idx] - local_median) - - if (deviation > threshold) { - new_kept_mask[idx] = false - removed_any = true - } - } - - kept_mask = new_kept_mask - iterations_used = iter + 1 - - if (!removed_any) break - } - - const kept_indices: number[] = [] - const removed_indices: number[] = [] - - for (let idx = 0; idx < len; idx++) { - if (kept_mask[idx]) { - kept_indices.push(idx) - } else { - removed_indices.push(idx) - } - } - - return { kept_indices, removed_indices, iterations_used } -} - // --- Helper Functions --- // Handle NaN/Infinity based on mode @@ -1062,90 +539,3 @@ export function clean_trajectory_props( return { props: result_props, quality: quality_reports } } - -// --- Utility Functions --- - -function median(values: number[]): number { - if (values.length === 0) return 0 - const sorted = [...values].sort((a, b) => a - b) - const mid = Math.floor(sorted.length / 2) - return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2 -} - -// Simple matrix operations for Savitzky-Golay -function transpose(matrix: number[][]): number[][] { - if (matrix.length === 0) return [] - const rows = matrix.length - const cols = matrix[0].length - const result: number[][] = Array.from({ length: cols }, () => Array(rows).fill(0)) - for (let row = 0; row < rows; row++) { - for (let col = 0; col < cols; col++) { - result[col][row] = matrix[row][col] - } - } - return result -} - -function multiply_matrices(matrix_a: number[][], matrix_b: number[][]): number[][] { - const rows_a = matrix_a.length - const cols_a = matrix_a[0]?.length ?? 0 - const cols_b = matrix_b[0]?.length ?? 0 - - const result: number[][] = Array.from({ length: rows_a }, () => Array(cols_b).fill(0)) - - for (let row = 0; row < rows_a; row++) { - for (let col = 0; col < cols_b; col++) { - for (let idx_k = 0; idx_k < cols_a; idx_k++) { - result[row][col] += matrix_a[row][idx_k] * matrix_b[idx_k][col] - } - } - } - - return result -} - -function invert_matrix(matrix: number[][]): number[][] | null { - const size = matrix.length - if (size === 0 || matrix[0].length !== size) return null - - // Create augmented matrix [A | I] - const aug: number[][] = matrix.map((row, idx) => [ - ...row, - ...Array.from({ length: size }, (_, jdx) => (idx === jdx ? 1 : 0)), - ]) - - // Gaussian elimination with partial pivoting - for (let col = 0; col < size; col++) { - // Find pivot - let max_row = col - for (let row = col + 1; row < size; row++) { - if (Math.abs(aug[row][col]) > Math.abs(aug[max_row][col])) { - max_row = row - } - } - - if (Math.abs(aug[max_row][col]) < 1e-10) { - return null // Singular - } // Swap rows - - ;[aug[col], aug[max_row]] = [aug[max_row], aug[col]] - - // Eliminate column - const pivot = aug[col][col] - for (let jdx = 0; jdx < 2 * size; jdx++) { - aug[col][jdx] /= pivot - } - - for (let row = 0; row < size; row++) { - if (row !== col) { - const factor = aug[row][col] - for (let jdx = 0; jdx < 2 * size; jdx++) { - aug[row][jdx] -= factor * aug[col][jdx] - } - } - } - } - - // Extract inverse - return aug.map((row) => row.slice(size)) -} diff --git a/src/lib/plot/core/data-transform.ts b/src/lib/plot/core/data-transform.ts index 415aeec4d..d4660880f 100644 --- a/src/lib/plot/core/data-transform.ts +++ b/src/lib/plot/core/data-transform.ts @@ -13,12 +13,15 @@ export const get_series_symbol = (series_idx: number): D3SymbolName => // Extract the primary color from a series data object. // Checks line stroke, then point fill (handling arrays), with fallback to default blue. -export const extract_series_color = (series_data: DataSeries): string => - (series_data.line_style?.stroke ?? +export const extract_series_color = (series_data: DataSeries): string => { + const color = + series_data.line_style?.stroke ?? (Array.isArray(series_data.point_style) ? series_data.point_style[0]?.fill - : series_data.point_style?.fill)) || // oxlint-disable-line @typescript-eslint/prefer-nullish-coalescing -- empty fill should use default color - `#4A9EFF` + : series_data.point_style?.fill) + if (color) return color + return DEFAULTS.scatter.point.color +} // Prepare legend data from series array export const prepare_legend_data = ( diff --git a/src/lib/plot/core/fill-utils.ts b/src/lib/plot/core/fill-utils.ts index d47a99aee..587ba064e 100644 --- a/src/lib/plot/core/fill-utils.ts +++ b/src/lib/plot/core/fill-utils.ts @@ -241,8 +241,7 @@ const LINE_CURVE_TO_FILL: Record = { 'catmull-rom': `catmullRom`, } const line_curve_to_fill = (curve: LineCurve | undefined): FillCurveType => - // ?? monotoneX guards an unknown string from an untyped (Python/JSON) caller, matching - // Line.svelte's `CURVE_FACTORIES[curve] ?? curveMonotoneX` fallback + // ?? monotoneX guards an unknown string from an untyped (Python/JSON) caller (curve ? LINE_CURVE_TO_FILL[curve] : undefined) ?? `monotoneX` // Resolve a boundary to native points + curve in data coordinates. `companion` supplies x @@ -493,6 +492,12 @@ const CURVE_MAP: Record = { const get_curve = (curve_type: FillCurveType): CurveFactory => CURVE_MAP[curve_type] ?? curveMonotoneX +// Resolve a public LineCurve (a series' `line_style.curve`) to its d3 CurveFactory. Single source +// of truth shared by Line.svelte and PlotMarginals.svelte (composes the LineCurve -> FillCurveType +// -> CurveFactory maps); unknown/undefined falls back to curveMonotoneX. +export const line_curve_factory = (curve: LineCurve | undefined): CurveFactory => + get_curve(line_curve_to_fill(curve)) + const trace = (points: readonly Pt[], curve_type: FillCurveType): string => line() .x((pt) => pt.x) diff --git a/src/lib/plot/core/index.ts b/src/lib/plot/core/index.ts index ffe6184a8..4a929eefd 100644 --- a/src/lib/plot/core/index.ts +++ b/src/lib/plot/core/index.ts @@ -4,6 +4,7 @@ export * from './data-cleaning' export * from './fill-utils' export * from './interactions' export * from './layout' +export * from './marginals' export * from './reference-line' export * from './scales' export * from './svg' diff --git a/src/lib/plot/core/layout.ts b/src/lib/plot/core/layout.ts index 63ae3d27f..9488329ca 100644 --- a/src/lib/plot/core/layout.ts +++ b/src/lib/plot/core/layout.ts @@ -7,6 +7,10 @@ export type Sides = { t?: number; b?: number; l?: number; r?: number } // Default gap between tick labels and axis labels export const LABEL_GAP_DEFAULT = 30 +// Default plot padding (px) reserved for axis ticks/labels, shared by +// Histogram/BarPlot/BoxPlot/BinnedScatterPlot (ScatterPlot keeps its own bespoke default) +export const DEFAULT_PLOT_PADDING: Required = { t: 20, b: 60, l: 60, r: 20 } + // X position for a right-side (y2) axis label: past the plot edge plus tick shift and // measured tick-label width (both zero when tick labels render inside the plot) export function y2_axis_label_x( @@ -97,28 +101,35 @@ export const calc_auto_padding = ({ y2_axis = {}, label_gap = LABEL_GAP_DEFAULT, }: AutoPaddingConfig): Required => { - const x2_ticks = x2_axis.tick_values ?? [] - const y_ticks = y_axis.tick_values ?? [] - const y_format = y_axis.format ?? `` - const y2_ticks = y2_axis.tick_values ?? [] - const y2_format = y2_axis.format ?? `` + // Padding for a vertical-axis side (y/y2): when ticks render, reserve widest tick label + gap + + // the rotated axis-title width (mirrors the `t` branch's AXIS_LABEL_HEIGHT) so a wide tick label + // can't overlap the title. With no tick labels (e.g. no y2 series) reserve nothing extra. + const side_pad = ( + axis: AxisConfig & { tick_values?: (string | number)[] }, + default_side: number, + ) => { + const ticks = axis.tick_values ?? [] + if (ticks.length === 0) return default_side + return Math.max( + default_side, + measure_max_tick_width(ticks, axis.format ?? ``) + + label_gap + + (axis.label ? AXIS_LABEL_HEIGHT : 0), + ) + } return { t: padding.t ?? - (x2_ticks.length > 0 + ((x2_axis.tick_values ?? []).length > 0 ? Math.max( default_padding.t, TICK_LABEL_HEIGHT + label_gap + (x2_axis.label ? AXIS_LABEL_HEIGHT : 0), ) : default_padding.t), b: padding.b ?? default_padding.b, - l: - padding.l ?? - Math.max(default_padding.l, measure_max_tick_width(y_ticks, y_format) + label_gap), - r: - padding.r ?? - Math.max(default_padding.r, measure_max_tick_width(y2_ticks, y2_format) + label_gap), + l: padding.l ?? side_pad(y_axis, default_padding.l), + r: padding.r ?? side_pad(y2_axis, default_padding.r), } } diff --git a/src/lib/plot/core/marginals.ts b/src/lib/plot/core/marginals.ts new file mode 100644 index 000000000..d7d381a83 --- /dev/null +++ b/src/lib/plot/core/marginals.ts @@ -0,0 +1,668 @@ +// Shared marginal-distribution model for all 2D plots. Pure and unit-tested: types, +// shorthand resolution, pad reservation, strip geometry, and the curve math (histogram / +// kde / cdf / rug). The PlotMarginals.svelte renderer consumes these; each plot only adapts +// its data to MarginalSeriesInput and folds reserve_marginal_pad into its `pad`. + +import type { Vec2 } from '$lib/math' +import type { Rect, Sides } from '$lib/plot/core/layout' +import type { LineCurve, ScaleType } from '$lib/plot/core/types' +import { get_scale_type_name } from '$lib/plot/core/types' +import { gaussian_kde } from '$lib/plot/box/kde' +import { bin } from 'd3-array' +import type { Snippet } from 'svelte' + +// Which side of the plot a marginal strip sits on +export type MarginalSide = `top` | `bottom` | `left` | `right` +// Built-in marginal renderers (a custom `snippet` handles anything else) +export type MarginalType = `histogram` | `kde` | `cdf` | `rug` +// Strip position within its reserved band: `flush` hugs the plot edge, `outer` sits beyond +// the axis ticks, `auto` picks flush on sides without an axis and outer on axis sides. +export type MarginalPlacement = `auto` | `flush` | `outer` +export type MarginalNormalize = `count` | `density` | `probability` +// Which plot axis a marginal binds to (defaults to the primary axis for its side) +export type MarginalAxisBinding = `x1` | `x2` | `y1` | `y2` + +export const MARGINAL_SIDES = [`top`, `bottom`, `left`, `right`] as const + +// Top/bottom strips run along the x (horizontal) positional axis; left/right run along y +const is_x_side = (side: MarginalSide): boolean => side === `top` || side === `bottom` + +// The 1-D curve a marginal renders. `bars` for histograms, `line` for kde/cdf, `rug` for ticks. +export type MarginalCurve = + | { kind: `bars`; bins: { pos0: number; pos1: number; value: number }[]; max: number } + | { kind: `line`; points: { pos: number; value: number }[]; max: number } + | { kind: `rug`; positions: number[] } + +// Context handed to a marginal's `reduce`/`data` callbacks +export interface MarginalComputeContext { + config: ResolvedMarginalConfig + side: MarginalSide + positional_range: Vec2 + scale_type: ScaleType +} + +export interface MarginalConfig { + type?: MarginalType // default `histogram` + size?: number // strip thickness in px (default 64) + gap?: number // gap between strip and plot decorations in px (default 6) + placement?: MarginalPlacement // default `auto` + axis?: MarginalAxisBinding // bind to a secondary axis (default primary for the side) + bins?: number // histogram bin count (default 60) + bandwidth?: number | `silverman` | `scott` // kde bandwidth (default `silverman`) + normalize?: MarginalNormalize // histogram normalization (default `count`) + per_series?: boolean // one curve per series, overlaid (default true) vs merged into one + // styling + color?: string // single color overriding per-series colors + fill?: string + fill_opacity?: number + stroke?: string + stroke_width?: number + opacity?: number + curve?: LineCurve // line interpolation for kde/cdf + value_range?: Vec2 // pin the marginal value axis (e.g. align two marginals) + value_axis?: boolean // draw a small value-axis (spine + ticks + title) on the strip (default true) + label?: string // value-axis title (default: auto per type — `CDF`, `density`, `count`, …) + // data override / transform / full custom render (precedence: snippet > reduce > data > series) + data?: readonly number[] | ((ctx: MarginalComputeContext) => readonly number[]) + reduce?: ( + values: number[], + weights: number[] | undefined, + ctx: MarginalComputeContext, + ) => MarginalCurve + snippet?: Snippet<[MarginalRenderContext]> + hover?: boolean // show a hover tooltip over this strip (default true) + tooltip?: Snippet<[MarginalHover]> // custom tooltip content (overrides built-in per-kind content) + class?: string + style?: string +} + +// MarginalConfig with every MARGINAL_DEFAULTS field resolved to a concrete value (so consumers +// read them directly, no per-use `?? default` — the defaults live only in MARGINAL_DEFAULTS) +export type ResolvedMarginalConfig = MarginalConfig & { + type: MarginalType + size: number + gap: number + placement: MarginalPlacement + per_series: boolean + bins: number + bandwidth: number | `silverman` | `scott` + stroke_width: number + opacity: number + value_axis: boolean +} + +// Per-side shorthand accepted in the `marginals` prop +export type MarginalSideInput = boolean | MarginalType | MarginalConfig +// The full `marginals` prop: a boolean/type shorthand, or a per-side map +export type MarginalsProp = + | boolean + | MarginalType + | Partial> + +// Fully-resolved per-side config (null = inactive side) +export type ResolvedMarginals = Record + +// Generic per-series input each plot adapts its data to. Top/bottom marginals summarize `x`, +// left/right summarize `y`; `weight` weights each value (e.g. bar heights for a Pareto CDF). +export interface MarginalSeriesInput { + x?: ArrayLike + y?: ArrayLike + weight?: ArrayLike + color: string + label?: string + visible?: boolean + // Which axis the series renders on; a marginal only summarizes series whose axis matches + // the side it binds to (so a top/x1 marginal ignores x2 series, a right/y1 marginal ignores y2) + x_axis?: `x1` | `x2` + y_axis?: `y1` | `y2` +} + +// One computed curve plus the series it came from (for rendering + the snippet context) +export interface MarginalSeriesCurve { + series_idx: number + color: string + label?: string + curve: MarginalCurve +} + +// A 1-D scale: data value -> pixel (positional axis or marginal-value direction) +export type ScaleFn = (value: number) => number + +// Scale fn, current range, and scale type for one axis a marginal can bind to. Plots pass an +// `axes` map (x1 + y1 required, x2/y2 optional) to PlotMarginals; x2/y2 fall back to x1/y1. +export interface MarginalAxis { + scale: ScaleFn + range: Vec2 + scale_type?: ScaleType + format?: string // axis number format, surfaced in marginal hover tooltips + tick_label?: (value: number) => string | undefined // map a position to a label (categorical axes) + label?: string // axis title (e.g. `Error`), used as the position-row label in hover tooltips +} +export type MarginalAxes = { + x1: MarginalAxis + x2?: MarginalAxis + y1: MarginalAxis + y2?: MarginalAxis +} + +// Build a MarginalAxis from a host plot's scale fn + current range, reading scale_type/format/label +// off the plot's AxisConfig (DRYs the per-axis object every host plot passes to PlotMarginals; the +// renderer defaults a missing scale_type to `linear`). `tick_label` maps a position to a label. +export const marginal_axis = ( + scale: ScaleFn, + range: Vec2, + axis: { scale_type?: ScaleType; format?: string; label?: string }, + tick_label?: (value: number) => string | undefined, +): MarginalAxis => ({ + scale, + range, + scale_type: axis.scale_type, + format: axis.format, + label: axis.label, + tick_label, +}) + +// Everything a custom `snippet` needs to render a strip from scratch +export interface MarginalRenderContext extends MarginalComputeContext { + rect: Rect + positional_scale: ScaleFn // data position -> pixel along the shared axis + value_scale: ScaleFn // marginal value -> pixel across the strip thickness + baseline: number // pixel of the value=0 edge (plot-facing) + curves: MarginalSeriesCurve[] + series: MarginalSeriesInput[] + format?: string // axis number format threaded from the host plot (for hover tooltips) + tick_label?: (value: number) => string | undefined // categorical position -> label (hover tooltips) + axis_title?: string // title of the shared positional axis (e.g. `Error`), for hover tooltips +} + +// Pixel tolerance for rug-tick hit-testing (a tick farther than this from the pointer is a miss) +export const MARGINAL_HIT_TOLERANCE_PX = 10 + +// Tooltip payload describing the marginal datum nearest the pointer (returned by marginal_hit) +export interface MarginalHover { + side: MarginalSide + x: number // wrapper px for tooltip placement (the pointer position) + y: number + color: string + label?: string + kind: MarginalCurve[`kind`] + pos: number // data position (bin center for bars, point/tick position otherwise) + pos0?: number // bin range (bars only) + pos1?: number + value?: number // bar value / line value (rug has none) + config: ResolvedMarginalConfig // for normalize-aware labels + a custom tooltip snippet + scale_type: ScaleType + format?: string // axis number format (when threaded through from the host plot) + pos_label?: string // categorical label for `pos` (e.g. BarPlot category name), if any + axis_title?: string // title of the shared positional axis (e.g. `Error`), if any +} + +// Pure hit-test: given a strip's render context and a pointer position in wrapper px, return the +// nearest datum as a MarginalHover (or null for a miss). ctx.curves is kind-homogeneous, so we +// branch once on the first curve's kind, matching only along the shared positional axis. Non-finite +// data is skipped (consistent with the renderer) so custom reduce/data curves can't yield ghost hits. +export function marginal_hit( + ctx: MarginalRenderContext, + px: number, + py: number, +): MarginalHover | null { + const { side, curves, positional_scale, value_scale, baseline, config, scale_type } = ctx + const is_x = is_x_side(side) + const pointer_pos = is_x ? px : py // along the shared positional axis + const pointer_cross = is_x ? py : px // across the strip thickness (the value direction) + const kind = curves[0]?.curve.kind + if (!kind) return null + const contains_cross = (value_px: number) => + Number.isFinite(value_px) && + pointer_cross >= Math.min(baseline, value_px) && + pointer_cross <= Math.max(baseline, value_px) + + const hover = ( + curve: MarginalSeriesCurve, + extra: { pos: number; pos0?: number; pos1?: number; value?: number }, + ): MarginalHover => ({ + side, + x: px, + y: py, + color: config.color ?? curve.color, + label: curve.label, + kind, + config, + scale_type, + format: ctx.format, + pos_label: ctx.tick_label?.(extra.pos), + axis_title: ctx.axis_title, + ...extra, + }) + + if (kind === `bars`) { + // Among bins whose rendered rect contains the pointer, pick the largest-value (tallest) bar. + // Baseline-anchored bars nest, so the tallest containing bar wins render-order independently. + let best: { + curve: MarginalSeriesCurve + bar: { pos0: number; pos1: number; value: number } + } | null = null + for (const series_curve of curves) { + if (series_curve.curve.kind !== `bars`) continue + for (const bar of series_curve.curve.bins) { + if (!Number.isFinite(bar.value) || bar.value <= 0) continue // finite positive only (matches renderer) + const edge_a = positional_scale(bar.pos0) + const edge_b = positional_scale(bar.pos1) + if (!Number.isFinite(edge_a) || !Number.isFinite(edge_b)) continue + if (pointer_pos < Math.min(edge_a, edge_b) || pointer_pos > Math.max(edge_a, edge_b)) { + continue + } + if (!contains_cross(value_scale(bar.value))) continue + if (!best || bar.value > best.bar.value) best = { curve: series_curve, bar } + } + } + if (!best) return null + const { bar } = best + return hover(best.curve, { + pos: (bar.pos0 + bar.pos1) / 2, + pos0: bar.pos0, + pos1: bar.pos1, + value: bar.value, + }) + } + + if (kind === `line`) { + // Each series fills from `baseline` to its curve. Among the fills that contain the pointer, pick + // the one whose curve reaches FURTHEST from the baseline (the outermost fill) — that's the curve + // the pointer visually sits within, and it's render-order independent so every series stays + // selectable where its fill is on top. For each series take its point nearest the pointer + // position (its value at the cursor). A pointer outside every fill is a miss. + type LineHit = { + curve: MarginalSeriesCurve + pos: number + value: number + px_pos: number + px_val: number + } + let chosen: LineHit | null = null + let best_extent = -1 + for (const series_curve of curves) { + if (series_curve.curve.kind !== `line`) continue + const pts: LineHit[] = [] + let min_pos = Infinity + let max_pos = -Infinity + for (const pt of series_curve.curve.points) { + const px_pos = positional_scale(pt.pos) + const px_val = value_scale(pt.value) + if (!Number.isFinite(px_pos) || !Number.isFinite(px_val)) continue + pts.push({ curve: series_curve, pos: pt.pos, value: pt.value, px_pos, px_val }) + min_pos = Math.min(min_pos, px_pos) + max_pos = Math.max(max_pos, px_pos) + } + if (pts.length < 2) continue // renderer skips line/area paths without two finite points + if (pointer_pos < min_pos || pointer_pos > max_pos) continue + let near: LineHit | null = null + let near_dist = Infinity + for (const pt of pts) { + const pos_dist = Math.abs(pt.px_pos - pointer_pos) + if (pos_dist < near_dist) { + near_dist = pos_dist + near = pt + } + } + if (!near || !contains_cross(near.px_val)) continue + const extent = Math.abs(near.px_val - baseline) + if (extent > best_extent) [best_extent, chosen] = [extent, near] + } + return chosen ? hover(chosen.curve, { pos: chosen.pos, value: chosen.value }) : null + } + + // rug: nearest tick along the positional axis, within a pixel tolerance (no value/cross axis) + let best: { curve: MarginalSeriesCurve; pos: number; dist: number } | null = null + for (const series_curve of curves) { + if (series_curve.curve.kind !== `rug`) continue + for (const pos of series_curve.curve.positions) { + const px_pos = positional_scale(pos) + if (!Number.isFinite(px_pos)) continue + const dist = Math.abs(px_pos - pointer_pos) + if (dist < (best?.dist ?? Infinity)) best = { curve: series_curve, pos, dist } + } + } + if (!best || best.dist > MARGINAL_HIT_TOLERANCE_PX) return null + return hover(best.curve, { pos: best.pos }) +} + +// Co-located defaults (mirrors REF_LINE_STYLE_DEFAULTS in types.ts). Deliberately NOT part of +// settings.ts DEFAULTS to avoid pulling marginals into the settings-UI JSON schema. +export const MARGINAL_DEFAULTS = { + type: `histogram` as MarginalType, + size: 64, + gap: 6, + placement: `auto` as MarginalPlacement, + per_series: true, + bins: 60, + bandwidth: `silverman` as const, + stroke_width: 1.5, + opacity: 1, + value_axis: true, +} + +// Normalize a single side's shorthand to a MarginalConfig, `false` (explicit disable), or +// undefined (not specified -> fall back to default) +const to_config = ( + input: MarginalSideInput | undefined, +): MarginalConfig | false | undefined => { + if (input === undefined) return undefined + if (input === false) return false + if (input === true) return {} + if (typeof input === `string`) return { type: input } + return input +} + +// Merge a user side-input over the plot's default side-input into a resolved config (or null) +const resolve_side = ( + user_input: MarginalSideInput | undefined, + default_input: MarginalSideInput | undefined, +): ResolvedMarginalConfig | null => { + const user = to_config(user_input) + if (user === false) return null // explicit disable wins over any default + const base = to_config(default_input) + const base_cfg = base === false ? undefined : base + if (user === undefined && base_cfg === undefined) return null // side inactive + return { ...MARGINAL_DEFAULTS, ...base_cfg, ...user } +} + +// Expand the `marginals` prop into a per-side resolved config, merging with the plot's +// `default_sides` (the sensible defaults a given plot enables when `marginals` is `true`). +// - `true`: enable exactly the plot's default sides +// - a type string: that type on the plot's default sides (or top+right if none), inheriting +// each default side's config (e.g. shared bins) +// - a per-side map: enable ONLY the sides the user lists (default sides do not auto-activate), +// merging the plot's per-side default config under the user's +export function normalize_marginals( + prop: MarginalsProp | undefined, + default_sides: Partial> = {}, +): ResolvedMarginals { + const result: ResolvedMarginals = { top: null, bottom: null, left: null, right: null } + if (prop == null || prop === false) return result + + if (prop === true) { + for (const side of MARGINAL_SIDES) + result[side] = resolve_side(undefined, default_sides[side]) + return result + } + if (typeof prop === `string`) { + const sides = + Object.keys(default_sides).length > 0 ? default_sides : { top: true, right: true } + for (const side of MARGINAL_SIDES) { + if (!(side in sides)) continue + result[side] = resolve_side(prop, default_sides[side]) + } + return result + } + // Explicit per-side map: only sides the user names become active + for (const side of MARGINAL_SIDES) { + if (prop[side] === undefined) continue + result[side] = resolve_side(prop[side], default_sides[side]) + } + return result +} + +// Padding (px) each plot must add to its `pad` to make room for active marginal strips +export function reserve_marginal_pad(resolved: ResolvedMarginals): Required { + const reserve = (cfg: ResolvedMarginalConfig | null) => (cfg ? cfg.size + cfg.gap : 0) + return { + t: reserve(resolved.top), + b: reserve(resolved.bottom), + l: reserve(resolved.left), + r: reserve(resolved.right), + } +} + +// Sum two padding objects (used to fold marginal reservation into the decoration pad) +export const add_sides = (a: Required, b: Required): Required => ({ + t: a.t + b.t, + b: a.b + b.b, + l: a.l + b.l, + r: a.r + b.r, +}) + +// Default axis a side binds to when `config.axis` is unset +export const default_axis_for_side = (side: MarginalSide): MarginalAxisBinding => + is_x_side(side) ? `x1` : `y1` + +// Which sides carry an axis (drives `auto` placement). Bottom/left hold the primary x/y axes and +// are always present; top/right only when the plot shows a secondary axis there. +export const marginal_axis_presence = ( + top: boolean, + right: boolean, +): Record => ({ top, bottom: true, left: true, right }) + +// Whether the strip should hug the plot edge (`flush`) vs sit beyond the ticks (`outer`) +const is_flush = (placement: MarginalPlacement, has_axis: boolean): boolean => + placement === `flush` || (placement === `auto` && !has_axis) + +// Pixel rect of a marginal strip. The cross dimension spans the plot area (so it aligns with +// the shared positional scale); the thickness is `config.size`, positioned per placement. +export function marginal_strip_rect( + side: MarginalSide, + pad: Required, + width: number, + height: number, + config: ResolvedMarginalConfig, + has_axis: boolean, +): Rect { + const { size, gap } = config + const flush = is_flush(config.placement, has_axis) + const is_x = is_x_side(side) + const grows_negative = side === `top` || side === `left` + // thickness-axis position of the strip's near edge: flush sits a gap from the plot, outer at + // the container edge (top/left strips go before the plot, bottom/right strips after it) + const plot_near = is_x ? pad.t : pad.l + const plot_far = is_x ? height - pad.b : width - pad.r + const container_far = is_x ? height : width + const thick = flush + ? grows_negative + ? plot_near - gap - size + : plot_far + gap + : grows_negative + ? 0 + : container_far - size + // cross axis spans the plot area so the strip aligns with the shared positional scale + const cross_min = is_x ? pad.l : pad.t + const cross = Math.max(0, (is_x ? width - pad.r : height - pad.b) - cross_min) + return is_x + ? { x: cross_min, y: thick, width: cross, height: size } + : { x: thick, y: cross_min, width: size, height: cross } +} + +// Linear value scale across a strip's thickness, growing away from the plot. `domain` is the +// marginal value range (typically [0, max]); `baseline` is the plot-facing value=domain[0] edge. +export function marginal_value_scale( + side: MarginalSide, + rect: Rect, + domain: Vec2, +): { scale: (value: number) => number; baseline: number } { + const [lo, hi] = domain + const span = hi - lo || 1 + const is_x = is_x_side(side) + const thickness = is_x ? rect.height : rect.width + const near = is_x ? rect.y : rect.x // strip edge at the smaller pixel coordinate + // top/left strips grow toward smaller pixel coords; their baseline (value=lo, plot-facing) sits + // at the far edge, and value increases in the negative pixel direction + const grows_negative = side === `top` || side === `left` + const baseline = grows_negative ? near + thickness : near + const sign = grows_negative ? -1 : 1 + return { scale: (val) => baseline + sign * ((val - lo) / span) * thickness, baseline } +} + +// Drop non-finite positions (and matching weights), restrict to the current positional range +// so every marginal type tracks zoom/pan consistently (histogram already clips via bin domain), +// and drop non-positive positions on a log axis. +const clean_pairs = ( + positions: ArrayLike, + weights: ArrayLike | undefined, + positional_range: Vec2, + scale_type: ScaleType, +): { positions: number[]; weights: number[] | undefined } => { + const [lo, hi] = positional_range // ascending (canonicalized by compute_marginal_curve) + const require_positive = get_scale_type_name(scale_type) === `log` + const out_pos: number[] = [] + const out_wts: number[] | undefined = weights ? [] : undefined + for (let idx = 0; idx < positions.length; idx++) { + const pos = positions[idx] + if (!Number.isFinite(pos) || pos < lo || pos > hi) continue + if (require_positive && pos <= 0) continue + if (weights && out_wts) { + const weight = weights[idx] + // skip non-finite and negative weights: a negative makes a weighted CDF non-monotone and + // skews histogram normalization. zero is kept (it contributes nothing and the renderer + // already drops zero-value bars, so skipping it would only needlessly drop rug/kde positions) + if (!Number.isFinite(weight) || weight < 0) continue + out_wts.push(weight) + } + out_pos.push(pos) + } + return { positions: out_pos, weights: out_wts } +} + +function compute_histogram( + positions: number[], + weights: number[] | undefined, + config: ResolvedMarginalConfig, + positional_range: Vec2, +): MarginalCurve { + const indices = positions.map((_, idx) => idx) + const binner = bin() + .domain(positional_range) + .thresholds(config.bins) + .value((idx) => positions[idx]) + const binned = binner(indices) + + let max = 0 + const bins = binned.map((items) => { + const value = weights ? items.reduce((sum, idx) => sum + weights[idx], 0) : items.length + if (value > max) max = value + return { pos0: items.x0 ?? 0, pos1: items.x1 ?? 0, value } + }) + + // count: raw; probability: value/total; density: value/(total*bin_width) + if (config.normalize && config.normalize !== `count`) { + const grand = bins.reduce((sum, item) => sum + item.value, 0) || 1 + max = 0 + for (const item of bins) { + const width = item.pos1 - item.pos0 + item.value = + config.normalize === `density` + ? item.value / (grand * (width || 1)) + : item.value / grand + if (item.value > max) max = item.value + } + } + return { kind: `bars`, bins, max } +} + +function compute_cdf(positions: number[], weights: number[] | undefined): MarginalCurve { + const order = positions.map((_, idx) => idx).sort((a, b) => positions[a] - positions[b]) + const total = weights ? weights.reduce((sum, weight) => sum + weight, 0) : positions.length + const points: { pos: number; value: number }[] = [] + let cum = 0 + for (const idx of order) { + cum += weights ? weights[idx] : 1 + const pos = positions[idx] + const value = total > 0 ? cum / total : 0 + // collapse ties so positions stay strictly increasing (keeps monotone curves well-behaved) + const last = points[points.length - 1] + if (last?.pos === pos) last.value = value + else points.push({ pos, value }) + } + return { kind: `line`, points, max: 1 } +} + +// Smallest strictly-positive value (Infinity if none): repairs a degenerate log lower bound (<= 0) +// so kde grids / histogram bins don't dip to non-renderable positions +const smallest_positive = (vals: number[]): number => + vals.reduce((min, val) => (val > 0 && val < min ? val : min), Infinity) + +function compute_kde( + positions: number[], + config: ResolvedMarginalConfig, + positional_range: Vec2, + scale_type: ScaleType, +): MarginalCurve { + // The grid spans the current positional range, so on a normal log axis (range[0] > 0) no + // clamping is needed. Only repair a degenerate log range whose lower bound is <= 0 (would + // otherwise emit grid points at <= 0 -> NaN pixels) by clamping to the smallest positive sample. + let clip: [number | null, number | null] | undefined + if (get_scale_type_name(scale_type) === `log` && positional_range[0] <= 0) { + const min_pos = smallest_positive(positions) + if (Number.isFinite(min_pos)) clip = [min_pos, null] + } + const kde = gaussian_kde(positions, { + bandwidth: config.bandwidth, + n_points: 100, + range: positional_range, + clip, + max_samples: 5000, + }) + let max = 0 + for (const density of kde.density) if (density > max) max = density + const points = kde.grid.map((pos, idx) => ({ pos, value: kde.density[idx] })) + return { kind: `line`, points, max } +} + +// Summarize 1-D values into a renderable curve per the marginal type. Filters non-finite +// inputs and returns an empty curve for empty input (so nothing renders). +export function compute_marginal_curve( + positions: ArrayLike, + weights: ArrayLike | undefined, + config: ResolvedMarginalConfig, + positional_range: Vec2, + scale_type: ScaleType, +): MarginalCurve { + // Canonicalize to an ascending range so a reversed (inverted-axis) range doesn't crash + // d3.bin (negative-length bin array) or empty the kde grid + const range: Vec2 = + positional_range[0] <= positional_range[1] + ? positional_range + : [positional_range[1], positional_range[0]] + const { positions: pos, weights: wts } = clean_pairs(positions, weights, range, scale_type) + // rug returns the raw positions (empty or not); other types short-circuit on empty input + if (config.type === `rug`) return { kind: `rug`, positions: pos } + if (pos.length === 0) { + return config.type === `histogram` + ? { kind: `bars`, bins: [], max: 0 } + : { kind: `line`, points: [], max: 0 } + } + if (config.type === `histogram`) { + // on a degenerate log axis (lower bound <= 0) bin over the smallest positive sample instead, so + // bins aren't spread below the smallest renderable position (mirrors compute_kde's clip) + const hist_range: Vec2 = + get_scale_type_name(scale_type) === `log` && range[0] <= 0 + ? [smallest_positive(pos), range[1]] + : range + return compute_histogram(pos, wts, config, hist_range) + } + if (config.type === `cdf`) return compute_cdf(pos, wts) + return compute_kde(pos, config, range, scale_type) +} + +// Max value across a set of curves (for a shared per-side value scale) +export const curves_max = (curves: MarginalCurve[]): number => + curves.reduce((max, curve) => (curve.kind === `rug` ? max : Math.max(max, curve.max)), 0) + +// Value-axis title for a strip: explicit `config.label` wins, else auto per type (histogram uses +// its normalization). `rug` has no value axis so returns an empty string. +export const default_marginal_label = (config: ResolvedMarginalConfig): string => { + if (config.label != null) return config.label + if (config.type === `cdf`) return `CDF` + if (config.type === `kde`) return `density` + if (config.type === `histogram`) return config.normalize ?? `count` + return `` +} + +// d3-format spec for the strip's value-axis tick labels, picked to suit each type's value units +export const marginal_value_format = (config: ResolvedMarginalConfig): string => { + if (config.type === `cdf`) return `.0%` + if (config.type === `histogram`) { + if (config.normalize === `probability`) return `.0%` + if (config.normalize === `density`) return `.2~g` + return `.3~s` + } + return `.2~g` // kde density +} diff --git a/src/lib/plot/core/scales.ts b/src/lib/plot/core/scales.ts index 4a2cfbaef..e950571cf 100644 --- a/src/lib/plot/core/scales.ts +++ b/src/lib/plot/core/scales.ts @@ -7,11 +7,13 @@ import type { SizeScaleConfig, TimeInterval, } from '$lib/plot' +import { get_d3_interpolator } from '$lib/colors' import { clamp01 } from '$lib/utils' import { get_arcsinh_threshold, get_scale_type_name, is_time_scale, + SCALE_DEFAULTS, } from '$lib/plot/core/types' import { extent, range } from 'd3-array' import type { ScaleContinuousNumeric, ScaleTime } from 'd3-scale' @@ -278,6 +280,27 @@ export function create_scale( return scaleLinear().domain(domain).range(output_range) } +// Build the four axis scales for a 2D plot in one call: x/x2 share the horizontal pixel span, +// y/y2 the inverted vertical one. Shared by BarPlot/BoxPlot/Histogram (identical layout math). +export function create_axis_scales( + axes: { x: A; x2: A; y: A; y2: A }, + ranges: { x: Vec2; x2: Vec2; y: Vec2; y2: Vec2 }, + pad: { l: number; r: number; t: number; b: number }, + width: number, + height: number, +) { + const x_px: Vec2 = [pad.l, width - pad.r] + const y_px: Vec2 = [height - pad.b, pad.t] + const scale = (axis: A, domain: Vec2, px: Vec2) => + create_scale(axis.scale_type ?? `linear`, domain, px) + return { + x: scale(axes.x, ranges.x, x_px), + x2: scale(axes.x2, ranges.x2, x_px), + y: scale(axes.y, ranges.y, y_px), + y2: scale(axes.y2, ranges.y2, y_px), + } +} + // Create a time scale for time-based data export const create_time_scale = (domain: Vec2, output_range: Vec2) => scaleTime() @@ -543,7 +566,7 @@ export function create_color_scale( const interpolator = typeof candidate_interpolator === `function` ? candidate_interpolator - : d3_sc.interpolateViridis + : get_d3_interpolator(SCALE_DEFAULTS.scheme) const [min_val, max_val] = (typeof color_scale_config === `string` ? undefined : color_scale_config.value_range) ?? auto_color_range @@ -614,7 +637,7 @@ export function create_size_scale( config: SizeScaleConfig, all_size_values: (number | null)[], ) { - const [min_radius, max_radius] = config.radius_range ?? [2, 10] + const [min_radius, max_radius] = config.radius_range ?? SCALE_DEFAULTS.radius const auto_range = all_size_values.length > 0 ? extent(all_size_values.filter((val): val is number => val !== null)) diff --git a/src/lib/plot/core/types.ts b/src/lib/plot/core/types.ts index 757757cac..c2e46eea3 100644 --- a/src/lib/plot/core/types.ts +++ b/src/lib/plot/core/types.ts @@ -1,13 +1,14 @@ import type { PaneProps, PaneToggleProps } from '$lib/overlays' import type { D3ColorSchemeName, D3InterpolateName } from '$lib/colors' import type { D3SymbolName } from '$lib/labels' -import type { Point2D, Point3D, Vec2, Vec3 } from '$lib/math' +import type { Point2D, Vec2 } from '$lib/math' import type { ComponentProps, Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' import type { TweenOptions } from 'svelte/motion' import type { Sides } from '$lib/plot/core/layout' import type PlotLegend from '$lib/plot/core/components/PlotLegend.svelte' import type { TicksOption } from '$lib/plot/core/scales' +import type { FillGradient } from '$lib/plot/core/types/fills' export type { TweenOptions } from 'svelte/motion' @@ -280,6 +281,35 @@ export type SizeScaleConfig = { value_range?: Vec2 } +// Shared defaults for color/size scales and colorbar layout (scatter, bar, binned-scatter, …) +const COLOR_BAR_WIDTH = 220 +export const COLOR_BAR_DEFAULTS = { + width: COLOR_BAR_WIDTH, + horizontal_bar_height: 16, + binned_bar_height: 10, + horizontal_footprint: { width: COLOR_BAR_WIDTH, height: 56 }, + vertical_footprint: { width: 56, height: 100 }, +} as const +// Shared color/size scale defaults for scatter, scatter-3d, binned-scatter, bar, … +const VIRIDIS: D3InterpolateName = `interpolateViridis` +const SCATTER_RADIUS: Vec2 = [2, 10] +const BINNED_RADIUS: Vec2 = [4, 12] +export const SCALE_DEFAULTS: { + scheme: D3InterpolateName + color: ColorScaleConfig + radius: Vec2 + binned_radius: Vec2 + size: SizeScaleConfig + size_3d: SizeScaleConfig +} = { + scheme: VIRIDIS, + color: { type: `linear`, scheme: VIRIDIS, value_range: undefined }, + radius: SCATTER_RADIUS, + binned_radius: BINNED_RADIUS, + size: { type: `linear`, radius_range: SCATTER_RADIUS, value_range: undefined }, + size_3d: { type: `linear`, radius_range: [0.05, 0.2], value_range: undefined }, +} + // Type guard for select value narrowing (avoids unsafe casts) const SCALE_TYPE_NAMES = new Set([`linear`, `log`, `arcsinh`, `time`]) export const is_scale_type_name = (val: string): val is ScaleTypeName => @@ -702,413 +732,8 @@ export const DEFAULT_SERIES_SYMBOLS = [ `Wye`, ] as const satisfies readonly D3SymbolName[] -// 3D point extending base Point with z coordinate (prefixed to avoid conflict with convex-hull) -export interface ScatterPoint3D> extends Point { - z: number -} - -// 3D data series extending DataSeries with z array -// Omit filtered_data since it uses 2D InternalPoint type, redeclare with 3D type -export interface DataSeries3D> extends Omit< - DataSeries, - `y_axis` | `filtered_data` -> { - z: readonly number[] - filtered_data?: InternalPoint3D[] -} - -// Internal 3D point for processing within ScatterPlot3D -export interface InternalPoint3D< - Metadata = Record, -> extends ScatterPoint3D { - series_idx: number - point_idx: number - color_value?: number | null - size_value?: number | null - point_style?: PointStyle -} - -// Surface types for 3D visualization -export type SurfaceType = `grid` | `parametric` | `triangulated` - -// Configuration for 3D surfaces -export interface Surface3DConfig { - id?: string | number - type: SurfaceType - // For grid surfaces: regular grid with z values - x_range?: Vec2 - y_range?: Vec2 - resolution?: number | Vec2 // grid resolution (x, y) - z_fn?: (x: number, y: number) => number - // For parametric surfaces: u,v parameterization - u_range?: Vec2 - v_range?: Vec2 - parametric_fn?: (u: number, v: number) => Point3D - // For triangulated surfaces: explicit geometry (only x,y,z needed, not scatter-specific fields) - points?: Point3D[] - triangles?: Vec3[] // indices into points array - // Appearance - color?: string - color_fn?: (x: number, y: number, z: number) => string - opacity?: number - wireframe?: boolean - wireframe_color?: string - wireframe_width?: number - visible?: boolean - // Double-sided rendering - double_sided?: boolean -} - -// Extended axis config for 3D (same as 2D but can add 3D-specific options) -export interface AxisConfig3D extends AxisConfig { - // 3D-specific axis options can be added here - show_plane?: boolean // Show grid plane for this axis - plane_opacity?: number -} - -// Display config extended for 3D -export interface DisplayConfig3D extends DisplayConfig { - z_grid?: boolean - z_zero_line?: boolean - show_axes?: boolean - show_axis_labels?: boolean - show_bounding_box?: boolean - show_grid?: boolean - // Projection settings - render point shadows on background planes - // Coordinate mapping: user X→Three.js X, user Y→Three.js Z, user Z→Three.js Y - projections?: { - xy?: boolean // Project onto XY plane (floor/ceiling) - fixes user Z - xz?: boolean // Project onto XZ plane (back wall) - fixes user Y - yz?: boolean // Project onto YZ plane (side wall) - fixes user X - } - projection_opacity?: number // 0-1, default 0.3 - projection_scale?: number // Relative to point size, default 0.5 -} - -// 3D scatter handler props -export interface Scatter3DHandlerProps> extends Omit< - HandlerProps, - `x_axis` | `x2_axis` | `y_axis` | `y2_axis` -> { - z: number - x_axis: AxisConfig3D - y_axis: AxisConfig3D - z_axis: AxisConfig3D - x_formatted: string - y_formatted: string - z_formatted: string - color_value?: number | null -} - -export type Scatter3DHandlerEvent> = - Scatter3DHandlerProps & { - event?: MouseEvent - point: InternalPoint3D - } - -// Camera projection types for 3D -export type CameraProjection3D = `perspective` | `orthographic` - -// 3D plot config extending base -export interface PlotConfig3D { - x_axis?: AxisConfig3D - y_axis?: AxisConfig3D - z_axis?: AxisConfig3D - display?: DisplayConfig3D -} - -// 3D style overrides -export interface StyleOverrides3D extends StyleOverrides { - point?: StyleOverrides[`point`] & { - sphere_segments?: number // Level of detail for sphere geometry - } -} - -// Controls config for 3D -export interface ControlsConfig3D extends ControlsConfig { - show_camera_controls?: boolean - show_surface_controls?: boolean -} - -// FillBoundary - references to data sources for fill regions -// Can reference series by index, by id, or specify constant/axis/function/data values -export type FillBoundary = - | { type: `series`; series_idx: number } - | { type: `series`; series_id: string | number } - | { type: `constant`; value: number } - | { type: `axis`; axis: `x` | `x2` | `y` | `y2`; value?: number } - | { type: `function`; fn: (coord: number) => number } - // x is optional; when omitted, values align to the companion boundary's x positions - | { type: `data`; values: readonly number[]; x?: readonly number[] } - | number // Shorthand for constant value - -// Styling types for fill regions - -// Gradient configuration for fill regions -export interface FillGradient { - type: `linear` | `radial` - // For linear gradients: angle in degrees (0 = horizontal left-to-right, 90 = vertical top-to-bottom) - angle?: number - // For radial gradients: center position (0-1 normalized) - center?: { x: number; y: number } - // Color stops: array of [offset (0-1), color] pairs - stops: readonly [number, string][] -} - -// Edge/stroke styling for fill region boundaries -export interface FillEdgeStyle { - color?: string - width?: number - dash?: string - opacity?: number -} - -// Hover state styling for fill regions -export interface FillHoverStyle { - fill?: string - fill_opacity?: number - edge?: FillEdgeStyle - cursor?: string - scale?: number // Scale factor for hover effect - stroke?: string // Outline color drawn around the region on hover (default: theme-aware contrast) - stroke_width?: number // Outline width in px on hover (default: 1.5) -} - -// Event type for fill region interactions -export interface FillHandlerEvent { - event: MouseEvent | KeyboardEvent - region_idx: number - region_id?: string | number - x: number // Data x-coordinate - y: number // Data y-coordinate - px: number // Pixel x-coordinate - py: number // Pixel y-coordinate - label?: string - metadata?: Record -} - -// z-index positions for rendering order (used by fill regions and reference lines) -export type LayerZIndex = `below-grid` | `below-lines` | `below-points` | `above-all` - -// Curve types supported for fill path generation (matching d3-shape curves) -export const FILL_CURVE_TYPES = [ - `linear`, - `monotoneX`, - `monotoneY`, - `step`, - `stepBefore`, - `stepAfter`, - `basis`, - `cardinal`, - `catmullRom`, - `natural`, -] as const -export type FillCurveType = (typeof FILL_CURVE_TYPES)[number] - -// Main configuration for a fill region -export interface FillRegion { - // Identity - id?: string | number - label?: string - - // Boundaries - define the top and bottom of the fill area - upper: FillBoundary - lower: FillBoundary - - // Optional range constraints - x_range?: [number | null, number | null] - y_range?: [number | null, number | null] - - // Conditional fill: only fill where condition is true - // Example: where: (x, y_upper, y_lower) => y_upper > y_lower - where?: (x: number, y_upper: number, y_lower: number) => boolean - - // Styling - fill?: string | FillGradient - fill_opacity?: number - edge_upper?: FillEdgeStyle - edge_lower?: FillEdgeStyle - curve?: FillCurveType - step_position?: number // For step curves: 0 = step, 0.5 = stepMiddle, 1 = stepEnd - - // Rendering - z_index?: LayerZIndex - visible?: boolean - - // Interactions - hover_style?: FillHoverStyle - on_click?: (event: FillHandlerEvent) => void - on_hover?: (event: FillHandlerEvent | null) => void - - // Legend - show_in_legend?: boolean - legend_group?: string - - // Metadata for user data - metadata?: Record -} - -// Convenience type for error bands (symmetric or asymmetric around a series) -export interface ErrorBand { - // Reference to the central series - series: - | { type: `series`; series_idx: number } - | { - type: `series` - series_id: string | number - } - - // Error values - can be symmetric (single value/array) or asymmetric (upper/lower) - error: - | number // Symmetric constant error - | readonly number[] // Symmetric per-point error - | { upper: number | readonly number[]; lower: number | readonly number[] } // Asymmetric - - // Styling (defaults applied if not specified) - fill?: string // Defaults to series color with reduced opacity - fill_opacity?: number // Defaults to 0.3 - edge_style?: FillEdgeStyle - - // Identity - id?: string | number - label?: string - show_in_legend?: boolean -} - -// Reference line styling -export interface RefLineStyle { - color?: string // default: 'currentColor' - width?: number // default: 1 - dash?: string // SVG stroke-dasharray, default: none (solid) - opacity?: number // default: 1 -} - -// Annotation/label for reference lines -export interface RefLineAnnotation { - text: string - position?: `start` | `center` | `end` // position along the line - side?: `above` | `below` | `left` | `right` // which side of line - offset?: { x?: number; y?: number } - gap?: number // pixels between line and annotation text, default: 8 - edge_padding?: number // pixels inward from plot edge at start/end, default: 4 - font_size?: string - font_family?: string - color?: string - background?: string - padding?: number - rotate?: boolean // rotate text to follow line angle -} - -// Event type for reference line interactions -export interface RefLineEvent { - event: MouseEvent | KeyboardEvent | FocusEvent - line_idx: number - line_id?: string | number - type: RefLine[`type`] - label?: string - metadata?: Record -} - -// Base properties shared by all reference line types -export interface RefLineBase { - id?: string | number - x_span?: [number | null, number | null] - y_span?: [number | null, number | null] - coord_mode?: `data` | `relative` // default: 'data' - x_axis?: `x1` | `x2` // for vertical lines with dual x-axes - y_axis?: `y1` | `y2` // for horizontal lines with dual y-axes - style?: RefLineStyle - annotation?: RefLineAnnotation - z_index?: LayerZIndex - visible?: boolean - hover_style?: RefLineStyle - on_click?: (event: RefLineEvent) => void - on_hover?: (event: RefLineEvent | null) => void - show_in_legend?: boolean - label?: string - legend_group?: string - metadata?: Record -} - -// Reference line value type - supports numbers, Dates, and ISO date strings -export type RefLineValue = number | Date | string - -// Flat discriminated union - type determines required fields -export type RefLine = RefLineBase & - ( - | { type: `horizontal`; y: RefLineValue } - | { type: `vertical`; x: RefLineValue } - | { type: `diagonal`; slope: number; intercept: number } - | { - type: `segment` - p1: [RefLineValue, RefLineValue] - p2: [RefLineValue, RefLineValue] - } - | { type: `line`; p1: [RefLineValue, RefLineValue]; p2: [RefLineValue, RefLineValue] } - ) - -// Default style values for reference lines -export const REF_LINE_STYLE_DEFAULTS: Required = { - color: `currentColor`, - width: 1, - dash: ``, - opacity: 1, -} as const - -type Ref3DBase = Omit< - RefLineBase, - | `coord_mode` - | `x_axis` - | `y_axis` - | `style` - | `hover_style` - | `annotation` - | `on_click` - | `on_hover` -> & { - z_span?: [number | null, number | null] -} - -// Base properties shared by all 3D reference line types -// Aligned with RefLineBase for future feature parity (interactions, annotations, etc.) -export interface RefLine3DBase extends Ref3DBase { - style?: RefLineStyle - hover_style?: RefLineStyle - annotation?: RefLineAnnotation - on_click?: (event: { line_idx: number; line_id?: string | number }) => void - on_hover?: (event: { line_idx: number; line_id?: string | number } | null) => void -} - -// 3D reference line - discriminated union -export type RefLine3D = RefLine3DBase & - ( - | { type: `x-axis`; y: number; z: number } // line parallel to x-axis - | { type: `y-axis`; x: number; z: number } // line parallel to y-axis - | { type: `z-axis`; x: number; y: number } // line parallel to z-axis - | { type: `segment`; p1: Vec3; p2: Vec3 } - | { type: `line`; p1: Vec3; p2: Vec3 } - ) - -// 3D reference plane styling -export interface RefPlaneStyle { - color?: string - opacity?: number - wireframe?: boolean - wireframe_color?: string - double_sided?: boolean -} - -// Base properties shared by all 3D reference plane types -export interface RefPlaneBase extends Ref3DBase { - style?: RefPlaneStyle -} - -// 3D reference plane - discriminated union -export type RefPlane = RefPlaneBase & - ( - | { type: `xy`; z: number } // horizontal plane at z - | { type: `xz`; y: number } // vertical plane at y - | { type: `yz`; x: number } // vertical plane at x - | { type: `normal`; normal: Vec3; point: Vec3 } - | { type: `points`; p1: Vec3; p2: Vec3; p3: Vec3 } // plane through 3 points - ) +// Sub-domain types live in ./types/* and are re-exported so existing +// `$lib/plot/core/types` import paths keep working. +export type * from '$lib/plot/core/types/plot-3d' +export * from '$lib/plot/core/types/fills' +export * from '$lib/plot/core/types/reference-lines' diff --git a/src/lib/plot/core/types/fills.ts b/src/lib/plot/core/types/fills.ts new file mode 100644 index 000000000..456a7d48b --- /dev/null +++ b/src/lib/plot/core/types/fills.ts @@ -0,0 +1,147 @@ +// Fill-region and error-band types for 2D plots (shaded areas between boundaries). +// Self-contained leaf module re-exported via $lib/plot/core/types. + +// FillBoundary - references to data sources for fill regions +// Can reference series by index, by id, or specify constant/axis/function/data values +export type FillBoundary = + | { type: `series`; series_idx: number } + | { type: `series`; series_id: string | number } + | { type: `constant`; value: number } + | { type: `axis`; axis: `x` | `x2` | `y` | `y2`; value?: number } + | { type: `function`; fn: (coord: number) => number } + // x is optional; when omitted, values align to the companion boundary's x positions + | { type: `data`; values: readonly number[]; x?: readonly number[] } + | number // Shorthand for constant value + +// Styling types for fill regions + +// Gradient configuration for fill regions +export interface FillGradient { + type: `linear` | `radial` + // For linear gradients: angle in degrees (0 = horizontal left-to-right, 90 = vertical top-to-bottom) + angle?: number + // For radial gradients: center position (0-1 normalized) + center?: { x: number; y: number } + // Color stops: array of [offset (0-1), color] pairs + stops: readonly [number, string][] +} + +// Edge/stroke styling for fill region boundaries +export interface FillEdgeStyle { + color?: string + width?: number + dash?: string + opacity?: number +} + +// Hover state styling for fill regions +export interface FillHoverStyle { + fill?: string + fill_opacity?: number + edge?: FillEdgeStyle + cursor?: string + scale?: number // Scale factor for hover effect + stroke?: string // Outline color drawn around the region on hover (default: theme-aware contrast) + stroke_width?: number // Outline width in px on hover (default: 1.5) +} + +// Event type for fill region interactions +export interface FillHandlerEvent { + event: MouseEvent | KeyboardEvent + region_idx: number + region_id?: string | number + x: number // Data x-coordinate + y: number // Data y-coordinate + px: number // Pixel x-coordinate + py: number // Pixel y-coordinate + label?: string + metadata?: Record +} + +// z-index positions for rendering order (used by fill regions and reference lines) +export type LayerZIndex = `below-grid` | `below-lines` | `below-points` | `above-all` + +// Curve types supported for fill path generation (matching d3-shape curves) +export const FILL_CURVE_TYPES = [ + `linear`, + `monotoneX`, + `monotoneY`, + `step`, + `stepBefore`, + `stepAfter`, + `basis`, + `cardinal`, + `catmullRom`, + `natural`, +] as const +export type FillCurveType = (typeof FILL_CURVE_TYPES)[number] + +// Main configuration for a fill region +export interface FillRegion { + // Identity + id?: string | number + label?: string + + // Boundaries - define the top and bottom of the fill area + upper: FillBoundary + lower: FillBoundary + + // Optional range constraints + x_range?: [number | null, number | null] + y_range?: [number | null, number | null] + + // Conditional fill: only fill where condition is true + // Example: where: (x, y_upper, y_lower) => y_upper > y_lower + where?: (x: number, y_upper: number, y_lower: number) => boolean + + // Styling + fill?: string | FillGradient + fill_opacity?: number + edge_upper?: FillEdgeStyle + edge_lower?: FillEdgeStyle + curve?: FillCurveType + step_position?: number // For step curves: 0 = step, 0.5 = stepMiddle, 1 = stepEnd + + // Rendering + z_index?: LayerZIndex + visible?: boolean + + // Interactions + hover_style?: FillHoverStyle + on_click?: (event: FillHandlerEvent) => void + on_hover?: (event: FillHandlerEvent | null) => void + + // Legend + show_in_legend?: boolean + legend_group?: string + + // Metadata for user data + metadata?: Record +} + +// Convenience type for error bands (symmetric or asymmetric around a series) +export interface ErrorBand { + // Reference to the central series + series: + | { type: `series`; series_idx: number } + | { + type: `series` + series_id: string | number + } + + // Error values - can be symmetric (single value/array) or asymmetric (upper/lower) + error: + | number // Symmetric constant error + | readonly number[] // Symmetric per-point error + | { upper: number | readonly number[]; lower: number | readonly number[] } // Asymmetric + + // Styling (defaults applied if not specified) + fill?: string // Defaults to series color with reduced opacity + fill_opacity?: number // Defaults to 0.3 + edge_style?: FillEdgeStyle + + // Identity + id?: string | number + label?: string + show_in_legend?: boolean +} diff --git a/src/lib/plot/core/types/plot-3d.ts b/src/lib/plot/core/types/plot-3d.ts new file mode 100644 index 000000000..881ca62d7 --- /dev/null +++ b/src/lib/plot/core/types/plot-3d.ts @@ -0,0 +1,143 @@ +// 3D plot types (scatter points, surfaces, axes, handlers) extending the 2D vocabulary. +// Imports the 2D core types from the barrel and is re-exported by it; nothing in core depends on +// these, so that re-export cycle is type-only (erased at build, hence harmless). + +import type { Point3D, Vec2, Vec3 } from '$lib/math' +import type { + AxisConfig, + ControlsConfig, + DataSeries, + DisplayConfig, + HandlerProps, + Point, + PointStyle, + StyleOverrides, +} from '$lib/plot/core/types' + +// 3D point extending base Point with z coordinate (prefixed to avoid conflict with convex-hull) +export interface ScatterPoint3D> extends Point { + z: number +} + +// 3D data series extending DataSeries with z array +// Omit filtered_data since it uses 2D InternalPoint type, redeclare with 3D type +export interface DataSeries3D> extends Omit< + DataSeries, + `y_axis` | `filtered_data` +> { + z: readonly number[] + filtered_data?: InternalPoint3D[] +} + +// Internal 3D point for processing within ScatterPlot3D +export interface InternalPoint3D< + Metadata = Record, +> extends ScatterPoint3D { + series_idx: number + point_idx: number + color_value?: number | null + size_value?: number | null + point_style?: PointStyle +} + +// Surface types for 3D visualization +export type SurfaceType = `grid` | `parametric` | `triangulated` + +// Configuration for 3D surfaces +export interface Surface3DConfig { + id?: string | number + type: SurfaceType + // For grid surfaces: regular grid with z values + x_range?: Vec2 + y_range?: Vec2 + resolution?: number | Vec2 // grid resolution (x, y) + z_fn?: (x: number, y: number) => number + // For parametric surfaces: u,v parameterization + u_range?: Vec2 + v_range?: Vec2 + parametric_fn?: (u: number, v: number) => Point3D + // For triangulated surfaces: explicit geometry (only x,y,z needed, not scatter-specific fields) + points?: Point3D[] + triangles?: Vec3[] // indices into points array + // Appearance + color?: string + color_fn?: (x: number, y: number, z: number) => string + opacity?: number + wireframe?: boolean + wireframe_color?: string + wireframe_width?: number + visible?: boolean + // Double-sided rendering + double_sided?: boolean +} + +// Extended axis config for 3D (same as 2D but can add 3D-specific options) +export interface AxisConfig3D extends AxisConfig { + // 3D-specific axis options can be added here + show_plane?: boolean // Show grid plane for this axis + plane_opacity?: number +} + +// Display config extended for 3D +export interface DisplayConfig3D extends DisplayConfig { + z_grid?: boolean + z_zero_line?: boolean + show_axes?: boolean + show_axis_labels?: boolean + show_bounding_box?: boolean + show_grid?: boolean + // Projection settings - render point shadows on background planes + // Coordinate mapping: user X→Three.js X, user Y→Three.js Z, user Z→Three.js Y + projections?: { + xy?: boolean // Project onto XY plane (floor/ceiling) - fixes user Z + xz?: boolean // Project onto XZ plane (back wall) - fixes user Y + yz?: boolean // Project onto YZ plane (side wall) - fixes user X + } + projection_opacity?: number // 0-1, default 0.3 + projection_scale?: number // Relative to point size, default 0.5 +} + +// 3D scatter handler props +export interface Scatter3DHandlerProps> extends Omit< + HandlerProps, + `x_axis` | `x2_axis` | `y_axis` | `y2_axis` +> { + z: number + x_axis: AxisConfig3D + y_axis: AxisConfig3D + z_axis: AxisConfig3D + x_formatted: string + y_formatted: string + z_formatted: string + color_value?: number | null +} + +export type Scatter3DHandlerEvent> = + Scatter3DHandlerProps & { + event?: MouseEvent + point: InternalPoint3D + } + +// Camera projection types for 3D +export type CameraProjection3D = `perspective` | `orthographic` + +// 3D plot config extending base +export interface PlotConfig3D { + x_axis?: AxisConfig3D + y_axis?: AxisConfig3D + z_axis?: AxisConfig3D + display?: DisplayConfig3D +} + +// 3D style overrides +export interface StyleOverrides3D extends StyleOverrides { + point?: StyleOverrides[`point`] & { + sphere_segments?: number // Level of detail for sphere geometry + } +} + +// Controls config for 3D +export interface ControlsConfig3D extends ControlsConfig { + show_camera_controls?: boolean + show_surface_controls?: boolean +} diff --git a/src/lib/plot/core/types/reference-lines.ts b/src/lib/plot/core/types/reference-lines.ts new file mode 100644 index 000000000..faa4cce64 --- /dev/null +++ b/src/lib/plot/core/types/reference-lines.ts @@ -0,0 +1,143 @@ +// Reference-line and reference-plane types for 2D and 3D plots. +// Depends only on LayerZIndex (from ./fills) and math vectors; re-exported via $lib/plot/core/types. + +import type { Vec3 } from '$lib/math' +import type { LayerZIndex } from '$lib/plot/core/types/fills' + +// Reference line styling +export interface RefLineStyle { + color?: string // default: 'currentColor' + width?: number // default: 1 + dash?: string // SVG stroke-dasharray, default: none (solid) + opacity?: number // default: 1 +} + +// Annotation/label for reference lines +export interface RefLineAnnotation { + text: string + position?: `start` | `center` | `end` // position along the line + side?: `above` | `below` | `left` | `right` // which side of line + offset?: { x?: number; y?: number } + gap?: number // pixels between line and annotation text, default: 8 + edge_padding?: number // pixels inward from plot edge at start/end, default: 4 + font_size?: string + font_family?: string + color?: string + background?: string + padding?: number + rotate?: boolean // rotate text to follow line angle +} + +// Event type for reference line interactions +export interface RefLineEvent { + event: MouseEvent | KeyboardEvent | FocusEvent + line_idx: number + line_id?: string | number + type: RefLine[`type`] + label?: string + metadata?: Record +} + +// Base properties shared by all reference line types +export interface RefLineBase { + id?: string | number + x_span?: [number | null, number | null] + y_span?: [number | null, number | null] + coord_mode?: `data` | `relative` // default: 'data' + x_axis?: `x1` | `x2` // for vertical lines with dual x-axes + y_axis?: `y1` | `y2` // for horizontal lines with dual y-axes + style?: RefLineStyle + annotation?: RefLineAnnotation + z_index?: LayerZIndex + visible?: boolean + hover_style?: RefLineStyle + on_click?: (event: RefLineEvent) => void + on_hover?: (event: RefLineEvent | null) => void + show_in_legend?: boolean + label?: string + legend_group?: string + metadata?: Record +} + +// Reference line value type - supports numbers, Dates, and ISO date strings +export type RefLineValue = number | Date | string + +// Flat discriminated union - type determines required fields +export type RefLine = RefLineBase & + ( + | { type: `horizontal`; y: RefLineValue } + | { type: `vertical`; x: RefLineValue } + | { type: `diagonal`; slope: number; intercept: number } + | { + type: `segment` + p1: [RefLineValue, RefLineValue] + p2: [RefLineValue, RefLineValue] + } + | { type: `line`; p1: [RefLineValue, RefLineValue]; p2: [RefLineValue, RefLineValue] } + ) + +// Default style values for reference lines +export const REF_LINE_STYLE_DEFAULTS: Required = { + color: `currentColor`, + width: 1, + dash: ``, + opacity: 1, +} as const + +type Ref3DBase = Omit< + RefLineBase, + | `coord_mode` + | `x_axis` + | `y_axis` + | `style` + | `hover_style` + | `annotation` + | `on_click` + | `on_hover` +> & { + z_span?: [number | null, number | null] +} + +// Base properties shared by all 3D reference line types +// Aligned with RefLineBase for future feature parity (interactions, annotations, etc.) +export interface RefLine3DBase extends Ref3DBase { + style?: RefLineStyle + hover_style?: RefLineStyle + annotation?: RefLineAnnotation + on_click?: (event: { line_idx: number; line_id?: string | number }) => void + on_hover?: (event: { line_idx: number; line_id?: string | number } | null) => void +} + +// 3D reference line - discriminated union +export type RefLine3D = RefLine3DBase & + ( + | { type: `x-axis`; y: number; z: number } // line parallel to x-axis + | { type: `y-axis`; x: number; z: number } // line parallel to y-axis + | { type: `z-axis`; x: number; y: number } // line parallel to z-axis + | { type: `segment`; p1: Vec3; p2: Vec3 } + | { type: `line`; p1: Vec3; p2: Vec3 } + ) + +// 3D reference plane styling +export interface RefPlaneStyle { + color?: string + opacity?: number + wireframe?: boolean + wireframe_color?: string + double_sided?: boolean +} + +// Base properties shared by all 3D reference plane types +export interface RefPlaneBase extends Ref3DBase { + style?: RefPlaneStyle +} + +// 3D reference plane - discriminated union +export type RefPlane = RefPlaneBase & + ( + | { type: `xy`; z: number } // horizontal plane at z + | { type: `xz`; y: number } // vertical plane at y + | { type: `yz`; x: number } // vertical plane at x + | { type: `normal`; normal: Vec3; point: Vec3 } + | { type: `points`; p1: Vec3; p2: Vec3; p3: Vec3 } // plane through 3 points + ) diff --git a/src/lib/plot/histogram/Histogram.svelte b/src/lib/plot/histogram/Histogram.svelte index b32aee1df..c5c9863c0 100644 --- a/src/lib/plot/histogram/Histogram.svelte +++ b/src/lib/plot/histogram/Histogram.svelte @@ -15,10 +15,12 @@ HistogramControls, PlotAxis, PlotLegend, + PlotMarginals, ReferenceLine, } from '$lib/plot' - import type { AxisChangeState } from '$lib/plot/core/axis-utils' - import { AXIS_DEFAULTS, create_axis_loader } from '$lib/plot/core/axis-utils' + import type { MarginalSeriesInput, MarginalsProp } from '$lib/plot/core/marginals' + import { add_sides, marginal_axis, marginal_axis_presence, normalize_marginals, reserve_marginal_pad } from '$lib/plot/core/marginals' + import { AXIS_DEFAULTS, type AxisChangeState, create_axis_loader } from '$lib/plot/core/axis-utils' import { extract_series_color, prepare_legend_data } from '$lib/plot/core/data-transform' import { create_placed_tween } from '$lib/plot/core/placed-tween.svelte' import { create_pan_zoom } from '$lib/plot/core/pan-zoom.svelte' @@ -30,6 +32,7 @@ } from '$lib/plot/core/interactions' import { calc_auto_padding, + DEFAULT_PLOT_PADDING, filter_padding, LABEL_GAP_DEFAULT, y2_axis_label_x, @@ -46,7 +49,7 @@ import type { IndexedRefLine } from '$lib/plot/core/reference-line' import { group_ref_lines_by_z, index_ref_lines } from '$lib/plot/core/reference-line' import { - create_scale, + create_axis_scales, generate_ticks, get_nice_data_range, get_tick_label, @@ -56,13 +59,15 @@ DataSeries, LegendConfig, PlotConfig, - ScaleType, } from '$lib/plot/core/types' - import { get_scale_type_name } from '$lib/plot/core/types' + import { + compute_count_range, + compute_histogram_bins, + log_safe_range, + } from '$lib/plot/histogram/histogram' import ZeroLines from '$lib/plot/core/components/ZeroLines.svelte' import ZoomRect from '$lib/plot/core/components/ZoomRect.svelte' import { DEFAULTS } from '$lib/settings' - import { bin, max } from 'd3-array' import type { Snippet } from 'svelte' import { onDestroy, untrack } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' @@ -79,7 +84,7 @@ y2_axis: y2_axis_init = {}, display: display_init = DEFAULTS.histogram.display, range_padding = 0.05, - padding = { t: 20, b: 60, l: 60, r: 20 }, + padding = DEFAULT_PLOT_PADDING, bins = $bindable(100), show_legend = $bindable(true), legend = {}, @@ -108,6 +113,7 @@ on_axis_change, on_error, pan = {}, + marginals = false, ...rest }: HTMLAttributes & BasePlotProps & PlotConfig & { series: DataSeries[] @@ -150,6 +156,7 @@ ) => void on_error?: (error: AxisLoadError) => void pan?: PanConfig + marginals?: MarginalsProp } = $props() // Local state for controls (initialized from props, owned by this component) @@ -264,57 +271,17 @@ ) : [0, 1] as Vec2 - // Calculate y-range for a specific set of series - const calc_y_range = ( - series_list: typeof selected_series, - y_limit: [number | null, number | null], - scale_type: ScaleType, - ): Vec2 => { - const type_name = get_scale_type_name(scale_type) - if (series_list.length === 0) { - const fallback = type_name === `log` ? 1 : 0 - return [fallback, 1] - } - // Bin each series over the domain of the x-axis it renders on (d3 bin() drops - // out-of-domain values, so binning x2 series over the x1 domain skews max_count) - const max_count = Math.max( - 0, - ...series_list.map((srs: DataSeries) => { - const hist = bin().domain(srs.x_axis === `x2` ? auto_x2 : auto_x).thresholds(bins) - return max(hist(srs.y), (data) => data.length) || 0 - }), - ) - - // If there's effectively no data, avoid log-range issues (counts can't be <= 0 on log) - if (max_count <= 0) { - const fallback = type_name === `log` ? 1 : 0 - return [fallback, 1] - } - - const [y0, y1] = get_nice_data_range( - [{ x: 0, y: 0 }, { x: max_count, y: 0 }], - ({ x }) => x, - y_limit, - scale_type, - range_padding, - false, - ) - // For log scale, minimum must be >= 1 (count can't be 0 on log) - // For linear/arcsinh, start from 0 - const y_min = type_name === `log` ? Math.max(1, y0) : Math.max(0, y0) - return [y_min, y1] - } - - const y1_range = calc_y_range( - y1_series, - final_y_axis.range ?? [null, null], - final_y_axis.scale_type ?? `linear`, - ) - const y2_auto_range = calc_y_range( - y2_series, - final_y2_axis.range ?? [null, null], - final_y2_axis.scale_type ?? `linear`, - ) + const count_cfg = { x_domain: auto_x, x2_domain: auto_x2, bin_count: bins, range_padding } + const y1_range = compute_count_range(y1_series, { + ...count_cfg, + scale_type: final_y_axis.scale_type ?? `linear`, + y_limit: log_safe_range(final_y_axis), + }) + const y2_auto_range = compute_count_range(y2_series, { + ...count_cfg, + scale_type: final_y2_axis.scale_type ?? `linear`, + y_limit: log_safe_range(final_y2_axis), + }) return { x: auto_x, x2: auto_x2, y: y1_range, y2: y2_auto_range } }) @@ -336,10 +303,16 @@ }) $effect(() => { - // Supports one-sided range pinning (null bounds fall back to auto); returns null - // for transient non-finite bounds (skip: writing NaN breaks scales and loops here) + // Supports one-sided range pinning (null bounds fall back to auto); returns null for transient + // non-finite bounds (skip: writing NaN breaks scales and loops here). y/y2 ranges are + // log-sanitized so an invalid (<= 0) log lower falls back to the auto count minimum. const next = resolve_axis_ranges( - { x: final_x_axis, x2: final_x2_axis, y: final_y_axis, y2: final_y2_axis }, + { + x: final_x_axis, + x2: final_x2_axis, + y: { range: log_safe_range(final_y_axis) }, + y2: { range: log_safe_range(final_y2_axis) }, + }, auto_ranges, ) if (!next) return @@ -354,9 +327,8 @@ }) // Layout: dynamic padding based on tick label widths - const default_padding = { t: 20, b: 60, l: 60, r: 20 } // base_pad reserves space for tick labels/axis titles; pad (below) adds decoration reservations - let base_pad = $derived(filter_padding(padding, default_padding)) + let base_pad = $derived(filter_padding(padding, DEFAULT_PLOT_PADDING)) // Update padding based on tick label widths (untrack breaks circular dependency) $effect(() => { @@ -367,12 +339,12 @@ const new_pad = width && height && current_ticks_y.length > 0 ? calc_auto_padding({ padding, - default_padding, + default_padding: DEFAULT_PLOT_PADDING, x2_axis: { ...final_x2_axis, tick_values: current_ticks_x2 }, y_axis: { ...final_y_axis, tick_values: current_ticks_y }, y2_axis: { ...final_y2_axis, tick_values: current_ticks_y2 }, }) - : filter_padding(padding, default_padding) + : filter_padding(padding, DEFAULT_PLOT_PADDING) // Add y2 axis label space (calc_auto_padding only accounts for tick labels) if ( @@ -457,60 +429,54 @@ : null, }) ) - const pad = $derived(decor.pad) + // Resolve marginals (default: CDF strip on top) and reserve outer-band padding. Pass the + // histogram's `bins` as the marginal's histogram bin count (NOT `size`/thickness) so a + // `histogram` marginal inherits the main binning via normalize_marginals' merge; `cdf` + // ignores `bins`. Samples lie along x and equal series.y, so the adapter maps y -> x. + const resolved_marginals = $derived( + normalize_marginals(marginals, { top: { type: `cdf`, bins } }), + ) + const pad = $derived(add_sides(decor.pad, reserve_marginal_pad(resolved_marginals))) + // a lone series uses the configured bar color; with multiple, each gets its own + const series_color = (series_data: DataSeries) => + selected_series.length === 1 ? final_bar.color : extract_series_color(series_data) + const marginal_series = $derived( + selected_series_entries.map(({ series_data }) => ({ + x: series_data.y ?? [], + color: series_color(series_data), + label: series_data.label, + visible: true, + x_axis: series_data.x_axis, + y_axis: series_data.y_axis, + })), + ) + const marginal_has_axis = $derived( + marginal_axis_presence(x2_series.length > 0, y2_series.length > 0), + ) const legend_auto_outside = $derived(decor.legend_outside) const legend_outside_x = $derived(decor.legend_pos.x) const legend_outside_y = $derived(decor.legend_pos.y) - // Scales and data - let scales = $derived({ - x: create_scale( - final_x_axis.scale_type ?? `linear`, - ranges.current.x, - [pad.l, width - pad.r], - ), - x2: create_scale( - final_x2_axis.scale_type ?? `linear`, - ranges.current.x2, - [pad.l, width - pad.r], - ), - y: create_scale( - final_y_axis.scale_type ?? `linear`, - ranges.current.y, - [height - pad.b, pad.t], - ), - y2: create_scale( - final_y2_axis.scale_type ?? `linear`, - ranges.current.y2, - [height - pad.b, pad.t], + // Scales and data (x/x2 share the horizontal pixel span, y/y2 the inverted vertical one) + let scales = $derived( + create_axis_scales( + { x: final_x_axis, x2: final_x2_axis, y: final_y_axis, y2: final_y2_axis }, + ranges.current, + pad, + width, + height, ), - }) + ) // Pad-independent binning (no pixel scales) so the auto-place obstacle field can reuse it let histogram_bins = $derived.by(() => { if (selected_series.length === 0 || !width || !height) return [] - const hist_generator = bin() - .domain([ranges.current.x[0], ranges.current.x[1]]) - .thresholds(bins) - const x2_hist_generator = x2_series.length > 0 - ? bin().domain([ranges.current.x2[0], ranges.current.x2[1]]).thresholds(bins) - : null - return selected_series_entries.map(({ series_data, series_idx }) => { - const use_x2 = series_data.x_axis === `x2` - const active_hist = use_x2 && x2_hist_generator ? x2_hist_generator : hist_generator - const bins_arr = active_hist(series_data.y) - return { - id: series_data.id ?? series_idx, - series_idx, - label: series_data.label || `Series ${series_idx + 1}`, - color: selected_series.length === 1 - ? final_bar.color - : extract_series_color(series_data), - bins: bins_arr, - max_count: max(bins_arr, (data) => data.length) || 0, - x_axis: series_data.x_axis, - y_axis: series_data.y_axis, - } + return compute_histogram_bins(selected_series_entries, { + x_domain: ranges.current.x, + x2_domain: ranges.current.x2, + has_x2: x2_series.length > 0, + bin_count: bins, + series_color, }) }) // Render-time data adds the pixel scales (pad-dependent) @@ -522,43 +488,23 @@ })), ) - let ticks = $derived({ - x: width && height - ? generate_ticks( - ranges.current.x, - final_x_axis.scale_type ?? `linear`, - final_x_axis.ticks, - scales.x, - { default_count: 8 }, - ) - : [], - x2: width && height && x2_series.length > 0 - ? generate_ticks( - ranges.current.x2, - final_x2_axis.scale_type ?? `linear`, - final_x2_axis.ticks, - scales.x2, - { default_count: 8 }, - ) - : [], - y: width && height - ? generate_ticks( - ranges.current.y, - final_y_axis.scale_type ?? `linear`, - final_y_axis.ticks, - scales.y, - { default_count: 6 }, - ) - : [], - y2: width && height && y2_series.length > 0 - ? generate_ticks( - ranges.current.y2, - final_y2_axis.scale_type ?? `linear`, - final_y2_axis.ticks, - scales.y2, - { default_count: 6 }, - ) - : [], + let ticks = $derived.by(() => { + // x/y always render; x2/y2 only when their series exist (else their scale is a [0,1] sentinel) + const axis_ticks = ( + axis: typeof final_x_axis, + range: Vec2, + scale: typeof scales.x, + default_count: number, + show = true, + ) => + width && height && show + ? generate_ticks(range, axis.scale_type ?? `linear`, axis.ticks, scale, { default_count }) + : [] + const x = axis_ticks(final_x_axis, ranges.current.x, scales.x, 8) + const x2 = axis_ticks(final_x2_axis, ranges.current.x2, scales.x2, 8, x2_series.length > 0) + const y = axis_ticks(final_y_axis, ranges.current.y, scales.y, 6) + const y2 = axis_ticks(final_y2_axis, ranges.current.y2, scales.y2, 6, y2_series.length > 0) + return { x, x2, y, y2 } }) // Cache measured tick-label widths so expensive text measurement only runs @@ -706,24 +652,16 @@ }) // State accessors for shared axis change handler + // Spread into existing state in each setter to preserve merged type structure const axis_state: AxisChangeState = { - get_axis: (axis) => { - if (axis === `x`) return x_axis - if (axis === `x2`) return x2_axis - if (axis === `y`) return y_axis - return y2_axis - }, - set_axis: (axis, config) => { - // Spread into existing state to preserve merged type structure - if (axis === `x`) x_axis = { ...x_axis, ...config } - else if (axis === `x2`) x2_axis = { ...x2_axis, ...config } - else if (axis === `y`) y_axis = { ...y_axis, ...config } - else y2_axis = { ...y2_axis, ...config } + axes: { + x: { get: () => x_axis, set: (config) => (x_axis = { ...x_axis, ...config }) }, + x2: { get: () => x2_axis, set: (config) => (x2_axis = { ...x2_axis, ...config }) }, + y: { get: () => y_axis, set: (config) => (y_axis = { ...y_axis, ...config }) }, + y2: { get: () => y2_axis, set: (config) => (y2_axis = { ...y2_axis, ...config }) }, }, - get_series: () => series, - set_series: (new_series) => (series = new_series), - get_loading: () => axis_loading, - set_loading: (axis) => (axis_loading = axis), + series: { get: () => series, set: (next) => (series = next) }, + loading: { get: () => axis_loading, set: (axis) => (axis_loading = axis) }, } // Shared handler + one-shot auto-load bound to this component's state @@ -863,10 +801,10 @@ 0} 0} {@render ref_lines_layer(ref_lines_by_z.above_all)} + + + diff --git a/src/lib/plot/histogram/histogram.ts b/src/lib/plot/histogram/histogram.ts new file mode 100644 index 000000000..0865c5e63 --- /dev/null +++ b/src/lib/plot/histogram/histogram.ts @@ -0,0 +1,114 @@ +// Pure binning/range helpers extracted from Histogram.svelte (no reactivity, no pixel scales) so the numeric logic is unit-testable. + +import { bin, max } from 'd3-array' +import type { Vec2 } from '$lib/math' +import { get_nice_data_range } from '$lib/plot/core/scales' +import type { AxisConfig, DataSeries, ScaleType } from '$lib/plot/core/types' +import { get_scale_type_name } from '$lib/plot/core/types' + +// [min, max] range where either bound may be null (unset) +type RangeLimit = [number | null, number | null] +// Shared x-domain + bin-count config for the binning helpers below +type BinConfig = { x_domain: Vec2; x2_domain: Vec2; bin_count: number } + +// On a log axis any bound <= 0 is invalid, so treat it as unset (null): callers then fall back to +// the positive count-based bound rather than pinning the log domain at <= 0 (a broken scale). +export function log_safe_range(axis: Pick): RangeLimit { + const [lo, hi] = axis.range ?? [null, null] + if (get_scale_type_name(axis.scale_type ?? `linear`) !== `log`) return [lo, hi] + // drop any bound <= 0 (guard the type first: `null <= 0` is true in JS) + const drop_non_positive = (bound: number | null) => + typeof bound === `number` && bound <= 0 ? null : bound + return [drop_non_positive(lo), drop_non_positive(hi)] +} + +// Bin each selected series over the domain of the x-axis it renders on (d3 bin() drops +// out-of-domain values). Pad-independent so the obstacle field can reuse it. +export function compute_histogram_bins( + entries: readonly { series_data: DataSeries; series_idx: number }[], + { + x_domain, + x2_domain, + has_x2, + bin_count, + series_color, + }: BinConfig & { + has_x2: boolean + series_color: (series_data: DataSeries) => string + }, +) { + const hist_generator = bin().domain([x_domain[0], x_domain[1]]).thresholds(bin_count) + const x2_hist_generator = has_x2 + ? bin().domain([x2_domain[0], x2_domain[1]]).thresholds(bin_count) + : null + return entries.map(({ series_data, series_idx }) => { + const use_x2 = series_data.x_axis === `x2` + const active_hist = use_x2 && x2_hist_generator ? x2_hist_generator : hist_generator + const bins_arr = active_hist(series_data.y) + return { + id: series_data.id ?? series_idx, + series_idx, + label: series_data.label ?? `Series ${series_idx + 1}`, + color: series_color(series_data), + bins: bins_arr, + max_count: max(bins_arr, (data) => data.length) ?? 0, + x_axis: series_data.x_axis, + y_axis: series_data.y_axis, + } + }) +} + +// Compute a nice [min, max] count range for a set of series, binning each over its own x-domain. +// On log count axes the lower bound sits just below the smallest non-empty bin so singleton tail +// bins don't collapse to zero height; linear/arcsinh axes start from 0. +export function compute_count_range( + series_list: readonly DataSeries[], + { + x_domain, + x2_domain, + bin_count, + scale_type, + y_limit, + range_padding, + }: BinConfig & { + scale_type: ScaleType + y_limit: RangeLimit + range_padding: number + }, +): Vec2 { + const type_name = get_scale_type_name(scale_type) + // no-data fallback: a positive floor on log (counts can't be <= 0), else 0 + const empty_range: Vec2 = [type_name === `log` ? 1 : 0, 1] + if (series_list.length === 0) return empty_range + const counts = series_list.flatMap((srs) => { + const hist = bin() + .domain(srs.x_axis === `x2` ? x2_domain : x_domain) + .thresholds(bin_count) + return hist(srs.y) + .map((data) => data.length) + .filter((count) => count > 0) + }) + const max_count = Math.max(0, ...counts) + + if (max_count <= 0) return empty_range + + const min_count = type_name === `log` ? Math.min(...counts) : 0 + const [y0, y1] = get_nice_data_range( + [ + { x: min_count, y: 0 }, + { x: max_count, y: 0 }, + ], + ({ x }) => x, + y_limit, + scale_type, + range_padding, + false, + ) + // For log count axes, start just below the smallest non-empty bin so singleton tail bins + // don't collapse to zero height at the baseline. y_limit is pre-sanitized log-safe, so a null + // (incl. dropped non-positive) lower correctly falls back to the positive minimum. + if (type_name === `log`) return [y_limit[0] ?? min_count / 1.1, y1] + + // For linear/arcsinh, start from 0 + return [Math.max(0, y0), y1] +} diff --git a/src/lib/plot/histogram/index.ts b/src/lib/plot/histogram/index.ts index a1d90b69c..15e2ce3f6 100644 --- a/src/lib/plot/histogram/index.ts +++ b/src/lib/plot/histogram/index.ts @@ -1,2 +1,3 @@ export { default as Histogram } from './Histogram.svelte' export { default as HistogramControls } from './HistogramControls.svelte' +export * from './histogram' diff --git a/src/lib/plot/scatter-3d/ScatterPlot3D.svelte b/src/lib/plot/scatter-3d/ScatterPlot3D.svelte index 351794091..7faff1f15 100644 --- a/src/lib/plot/scatter-3d/ScatterPlot3D.svelte +++ b/src/lib/plot/scatter-3d/ScatterPlot3D.svelte @@ -19,12 +19,12 @@ LegendConfig, RefLine3D, RefPlane, - ScaleType, Scatter3DHandlerEvent, SizeScaleConfig, StyleOverrides3D, Surface3DConfig, } from '$lib/plot/core/types' + import { SCALE_DEFAULTS } from '$lib/plot/core/types' import { Canvas } from '@threlte/core' import * as extras from '@threlte/extras' import { onMount } from 'svelte' @@ -54,17 +54,9 @@ }, styles = {}, // Color and size scaling - color_scale = { - type: `linear` as ScaleType, - scheme: `interpolateViridis` as D3InterpolateName, - value_range: undefined, - }, + color_scale = SCALE_DEFAULTS.color, color_bar = {}, - size_scale = { - type: `linear` as ScaleType, - radius_range: [0.05, 0.2], - value_range: undefined, - }, + size_scale = SCALE_DEFAULTS.size_3d, // Legend legend = {}, // Camera settings diff --git a/src/lib/plot/scatter-3d/ScatterPlot3DScene.svelte b/src/lib/plot/scatter-3d/ScatterPlot3DScene.svelte index c018a3136..7db0974a4 100644 --- a/src/lib/plot/scatter-3d/ScatterPlot3DScene.svelte +++ b/src/lib/plot/scatter-3d/ScatterPlot3DScene.svelte @@ -17,6 +17,7 @@ StyleOverrides3D, Surface3DConfig, } from '$lib/plot/core/types' + import { SCALE_DEFAULTS } from '$lib/plot/core/types' import { bind_renderer } from '$lib/scene' import { T, useTask } from '@threlte/core' import * as extras from '@threlte/extras' @@ -46,7 +47,7 @@ ref_lines = [], ref_planes = [], color_scale_fn = () => get_series_color(0), - size_scale = { type: `linear`, radius_range: [0.05, 0.2] }, + size_scale = SCALE_DEFAULTS.size_3d, camera_position = [10, 10, 10] as Vec3, camera_projection = `perspective` as CameraProjection3D, auto_rotate = 0, diff --git a/src/lib/plot/scatter/BinnedScatterPlot.svelte b/src/lib/plot/scatter/BinnedScatterPlot.svelte index bed5a85b1..01f031303 100644 --- a/src/lib/plot/scatter/BinnedScatterPlot.svelte +++ b/src/lib/plot/scatter/BinnedScatterPlot.svelte @@ -13,10 +13,14 @@ import { create_pulse_animation } from '$lib/effects.svelte' import ColorBar from '$lib/plot/core/components/ColorBar.svelte' import PlotAxis from '$lib/plot/core/components/PlotAxis.svelte' + import PlotMarginals from '$lib/plot/core/components/PlotMarginals.svelte' import PlotTooltip from '$lib/plot/core/components/PlotTooltip.svelte' import ZoomRect from '$lib/plot/core/components/ZoomRect.svelte' - import { compute_element_placement, filter_padding } from '$lib/plot/core/layout' + import { compute_element_placement, DEFAULT_PLOT_PADDING, filter_padding } from '$lib/plot/core/layout' import type { Sides } from '$lib/plot/core/layout' + import { get_series_color } from '$lib/plot/core/data-transform' + import type { MarginalSeriesInput, MarginalsProp } from '$lib/plot/core/marginals' + import { add_sides, marginal_axis, marginal_axis_presence, normalize_marginals, reserve_marginal_pad } from '$lib/plot/core/marginals' import { build_pick_index, bin_points, @@ -32,6 +36,7 @@ import type { DensityBin, DenseInternalPoint, DensePointSeries } from '$lib/plot/scatter/adaptive-density' import { create_color_scale, create_scale, create_size_scale, generate_ticks } from '$lib/plot/core/scales' import type { AxisConfig, DataSeries, InternalPoint, ScatterHandlerProps } from '$lib/plot/core/types' + import { COLOR_BAR_DEFAULTS, SCALE_DEFAULTS } from '$lib/plot/core/types' import { compute_label_positions, estimate_label_size, @@ -43,7 +48,6 @@ import type { HTMLAttributes } from 'svelte/elements' import { SvelteMap, SvelteSet } from 'svelte/reactivity' import type { - BinnedColorScaleConfig, BinnedDensityConfig, BinnedOverlaysConfig, BinnedPointDataFn, @@ -52,6 +56,7 @@ BinnedPointTooltipPayload, BinnedSizeScaleConfig, } from '$lib/plot/scatter/binned-scatter-types' + import { DEFAULT_BINNED_SIZE_SCALE } from '$lib/plot/scatter/binned-scatter-types' type RenderMode = `density` | `points` type DensePointEvent = { @@ -65,29 +70,15 @@ event: MouseEvent } type OverlayContext = { height: number; width: number; fullscreen: boolean } - const default_color_bar_size = { width: 220, height: 10 } const default_density_auto_point_mode = { max_points: 25_000, max_points_per_px: 0.12 } const default_density_color_bar: ComponentProps = { title: `Density` } - const default_density_color_scale = { - type: `linear`, - scheme: `interpolateViridis`, - } satisfies Exclude - const default_pad = { l: 64, r: 24, t: 24, b: 56 } - const default_point_radius_range: Vec2 = [4, 12] - const default_pick_radius = default_point_radius_range[1] - const default_size_scale = { - type: `linear`, - radius_range: default_point_radius_range, - pick_radius: default_pick_radius, - } satisfies BinnedSizeScaleConfig const max_placement_bins = 500 - const default_point_color = `#4dabf7` let { series, x_axis = {}, y_axis = {}, - size_scale = default_size_scale, + size_scale = DEFAULT_BINNED_SIZE_SCALE, density: density_config = {}, overlays: overlays_config = {}, padding: padding_config = {}, @@ -103,6 +94,7 @@ fullscreen_toggle = true, children, header_controls, + marginals = false, ...rest }: Omit, `children`> & { series: DensePointSeries[] @@ -124,6 +116,7 @@ fullscreen_toggle?: boolean children?: Snippet<[OverlayContext]> header_controls?: Snippet<[OverlayContext]> + marginals?: MarginalsProp } = $props() let canvas = $state() @@ -143,10 +136,30 @@ let label_sizes = new SvelteMap() const clip_path_id = `binned-scatter-plot-area-${next_clip_id++}` - let pad = $derived(filter_padding(padding_config, default_pad)) + const resolved_marginals = $derived( + normalize_marginals(marginals, { top: true, right: true }), + ) + // Unlike the other 2D plots this one doesn't auto-grow padding for tick labels, so this + // shared default is its final pad (merged with any user `padding`), not just a floor. + let pad = $derived( + add_sides( + filter_padding(padding_config, DEFAULT_PLOT_PADDING), + reserve_marginal_pad(resolved_marginals), + ), + ) + const marginal_series = $derived( + series.map((srs, idx) => ({ + x: srs.x, + y: srs.y, + color: srs.color ?? get_series_color(idx), + label: srs.label, + visible: true, + })), + ) + const marginal_has_axis = marginal_axis_presence(false, false) let density_settings = $derived({ bin_px: density_config.bin_px ?? 2.8, - color_scale: density_config.color_scale ?? default_density_color_scale, + color_scale: density_config.color_scale ?? SCALE_DEFAULTS.color, color_bar: density_config.color_bar === undefined ? default_density_color_bar : density_config.color_bar, auto_point_mode: density_config.auto_point_mode === undefined ? default_density_auto_point_mode @@ -267,7 +280,7 @@ tick_labels: color_bar.tick_labels ?? 4, tick_side: color_bar.tick_side ?? `primary`, bar_style: color_bar.bar_style ?? - `width: ${default_color_bar_size.width}px; height: ${default_color_bar_size.height}px; ${color_bar.style ?? ``}`, + `width: ${COLOR_BAR_DEFAULTS.width}px; height: ${COLOR_BAR_DEFAULTS.binned_bar_height}px; ${color_bar.style ?? ``}`, } }) let density_placement_points = $derived.by(() => { @@ -308,7 +321,7 @@ // renders; compute_element_placement measures the real footprint once laid out const fallback_size = is_vertical ? { width: 56, height: 120 } - : { width: default_color_bar_size.width, height: 50 } + : { width: COLOR_BAR_DEFAULTS.width, height: 50 } return compute_element_placement({ plot_bounds: plot_rect, @@ -346,12 +359,12 @@ return values }) let size_scale_fn = $derived(create_size_scale(size_scale, all_size_values)) - let min_point_radius = $derived(size_scale.radius_range?.[0] ?? default_point_radius_range[0]) - let max_point_radius = $derived(size_scale.radius_range?.[1] ?? default_point_radius_range[1]) + let min_point_radius = $derived(size_scale.radius_range?.[0] ?? SCALE_DEFAULTS.binned_radius[0]) + let max_point_radius = $derived(size_scale.radius_range?.[1] ?? SCALE_DEFAULTS.binned_radius[1]) let pick_radius_px = $derived( size_scale.pick_radius === `auto` ? max_point_radius - : size_scale.pick_radius ?? default_pick_radius, + : size_scale.pick_radius ?? SCALE_DEFAULTS.binned_radius[1], ) $effect(() => { @@ -432,7 +445,7 @@ const [y_min, y_max] = range_bounds(y_range) const pulse = selected_pulse.unit for (const [series_idx, srs] of series.entries()) { - ctx.fillStyle = srs.color ?? default_point_color + ctx.fillStyle = srs.color ?? get_series_color(series_idx) const n_points = Math.min(srs.x.length, srs.y.length) for (let point_idx = 0; point_idx < n_points; point_idx++) { const x = srs.x[point_idx] @@ -452,7 +465,7 @@ ctx.fill() if (is_selected) { ctx.globalAlpha = 0.35 + 0.25 * pulse - ctx.strokeStyle = srs.color ?? default_point_color + ctx.strokeStyle = srs.color ?? get_series_color(series_idx) ctx.lineWidth = 1.5 + pulse ctx.beginPath() ctx.arc(cx, cy, radius * (1.45 + 0.25 * pulse), 0, 2 * Math.PI) @@ -513,7 +526,7 @@ }) const point_color = (point: DenseInternalPoint): string => - series[point.series_idx]?.color ?? default_point_color + series[point.series_idx]?.color ?? get_series_color(point.series_idx) const point_label_key = (point: DenseInternalPoint): string => `${point.series_idx}-${point.point_idx}` @@ -905,6 +918,21 @@ {/each} {/if} + + + {#if point_labels_settings.render && point_label_payloads.length} diff --git a/src/lib/plot/scatter/ScatterPlot.svelte b/src/lib/plot/scatter/ScatterPlot.svelte index 2bcddca4d..4a3bd4696 100644 --- a/src/lib/plot/scatter/ScatterPlot.svelte +++ b/src/lib/plot/scatter/ScatterPlot.svelte @@ -40,6 +40,7 @@ Line, PlotAxis, PlotLegend, + PlotMarginals, PlotTooltip, ReferenceLine, ScatterPlotControls, @@ -47,20 +48,23 @@ ZeroLines, ZoomRect, } from '$lib/plot' + import type { MarginalSeriesInput, MarginalsProp } from '$lib/plot/core/marginals' + import { add_sides, marginal_axis, marginal_axis_presence, normalize_marginals, reserve_marginal_pad } from '$lib/plot/core/marginals' import { build_obstacles_norm, has_explicit_position, measured_footprint, place_decorations, } from '$lib/plot/core/auto-place' - import type { AxisChangeState } from '$lib/plot/core/axis-utils' - import { AXIS_DEFAULTS, create_axis_loader } from '$lib/plot/core/axis-utils' + import { AXIS_DEFAULTS, type AxisChangeState, create_axis_loader } from '$lib/plot/core/axis-utils' import { get_series_color, get_series_symbol } from '$lib/plot/core/data-transform' import { create_placed_tween } from '$lib/plot/core/placed-tween.svelte' import { + COLOR_BAR_DEFAULTS, DEFAULT_MARKERS, get_scale_type_name, is_time_scale, + SCALE_DEFAULTS, } from '$lib/plot/core/types' import { compute_label_positions } from '$lib/plot/core/utils/label-placement' import { create_legend_visibility } from '$lib/plot/core/utils/series-visibility' @@ -130,13 +134,9 @@ tooltip, user_content, change = () => {}, - color_scale = { - type: `linear`, - scheme: `interpolateViridis`, - value_range: undefined, - }, + color_scale = SCALE_DEFAULTS.color, color_bar = {}, - size_scale = { type: `linear`, radius_range: [2, 10], value_range: undefined }, + size_scale = SCALE_DEFAULTS.size, label_placement_config = {}, hover_config = {}, legend = {}, @@ -163,6 +163,7 @@ on_axis_change, on_error, pan = {}, + marginals = false, ...rest }: HTMLAttributes & Omit & PlotConfig & { series?: DataSeries[] @@ -224,6 +225,7 @@ ) => void on_error?: (error: AxisLoadError) => void pan?: PanConfig + marginals?: MarginalsProp } = $props() // Merged axis/display values with defaults (use $derived to avoid breaking $bindable) @@ -403,8 +405,8 @@ colorbar_element?.offsetWidth && colorbar_element?.offsetHeight ? measure_full_footprint(colorbar_element) : colorbar_is_horizontal - ? { width: 220, height: 56 } - : { width: 56, height: 100 }, + ? COLOR_BAR_DEFAULTS.horizontal_footprint + : COLOR_BAR_DEFAULTS.vertical_footprint, ) const legend_footprint = $derived(measured_footprint(legend_element, { width: 120, height: 80 })) const legend_has_explicit_pos = $derived(has_explicit_position(legend?.style)) @@ -458,7 +460,32 @@ : null, }) ) - const pad = $derived(decor.pad) + // Resolve marginals and reserve outer-band padding so the plot shrinks to make room + const resolved_marginals = $derived( + normalize_marginals(marginals, { top: true, right: true }), + ) + const pad = $derived(add_sides(decor.pad, reserve_marginal_pad(resolved_marginals))) + // Map series to the generic marginal input, reusing the line/legend color fallback + const marginal_series = $derived( + series_with_ids.map((srs, idx) => { + const point_fill = Array.isArray(srs?.point_style) + ? srs.point_style[0]?.fill + : srs?.point_style?.fill + return { + x: srs?.x ?? [], + y: srs?.y ?? [], + color: srs?.line_style?.stroke ?? point_fill ?? + get_series_color(srs?.orig_series_idx ?? idx), + label: srs?.label, + visible: srs?.visible ?? true, + x_axis: srs?.x_axis, + y_axis: srs?.y_axis, + } + }), + ) + const marginal_has_axis = $derived( + marginal_axis_presence(x2_points.length > 0, y2_points.length > 0), + ) const legend_auto_outside = $derived(decor.legend_outside) const legend_outside_x = $derived(decor.legend_pos.x) const legend_outside_y = $derived(decor.legend_pos.y) @@ -884,8 +911,8 @@ // renders; compute_element_placement measures the real footprint once it's laid out const is_horizontal = (color_bar.orientation ?? `horizontal`) === `horizontal` const colorbar_size = is_horizontal - ? { width: 220, height: 56 } - : { width: 56, height: 100 } + ? COLOR_BAR_DEFAULTS.horizontal_footprint + : COLOR_BAR_DEFAULTS.vertical_footprint // Build exclusion rects (avoid legend if it's placed) const exclude_rects: Rect[] = [] @@ -1298,24 +1325,16 @@ }) // State accessors for shared axis change handler + // Spread into existing state in each setter to preserve merged type structure const axis_state: AxisChangeState> = { - get_axis: (axis) => { - if (axis === `x`) return x_axis - if (axis === `x2`) return x2_axis - if (axis === `y`) return y_axis - return y2_axis - }, - set_axis: (axis, config) => { - // Spread into existing state to preserve merged type structure - if (axis === `x`) x_axis = { ...x_axis, ...config } - else if (axis === `x2`) x2_axis = { ...x2_axis, ...config } - else if (axis === `y`) y_axis = { ...y_axis, ...config } - else y2_axis = { ...y2_axis, ...config } + axes: { + x: { get: () => x_axis, set: (config) => (x_axis = { ...x_axis, ...config }) }, + x2: { get: () => x2_axis, set: (config) => (x2_axis = { ...x2_axis, ...config }) }, + y: { get: () => y_axis, set: (config) => (y_axis = { ...y_axis, ...config }) }, + y2: { get: () => y2_axis, set: (config) => (y2_axis = { ...y2_axis, ...config }) }, }, - get_series: () => series, - set_series: (new_series) => (series = new_series), - get_loading: () => axis_loading, - set_loading: (axis) => (axis_loading = axis), + series: { get: () => series, set: (next) => (series = next) }, + loading: { get: () => axis_loading, set: (axis) => (axis_loading = axis) }, } // Shared handler + one-shot auto-load bound to this component's state @@ -1774,6 +1793,23 @@ {@render fill_regions_layer(fills_by_z.above_all)} {@render ref_lines_layer(ref_lines_by_z.above_all)} + + + @@ -1866,7 +1902,7 @@ color_scale_domain={color_domain} scale_type={typeof color_scale === `string` ? undefined : color_scale.type} range={color_domain?.every((val) => val != null) ? color_domain : undefined} - bar_style="width: 220px; height: 16px; {color_bar?.style ?? ``}" + bar_style="width: {COLOR_BAR_DEFAULTS.width}px; height: {COLOR_BAR_DEFAULTS.horizontal_bar_height}px; {color_bar?.style ?? ``}" {...color_bar} wrapper_style={effective_cbar_wrapper_style ? `height: 100%; width: 100%;` : ``} /> diff --git a/src/lib/plot/scatter/binned-scatter-types.ts b/src/lib/plot/scatter/binned-scatter-types.ts index ca0e49bb5..df67f12cd 100644 --- a/src/lib/plot/scatter/binned-scatter-types.ts +++ b/src/lib/plot/scatter/binned-scatter-types.ts @@ -7,12 +7,19 @@ import type { ScatterHandlerProps, SizeScaleConfig, } from '$lib/plot/core/types' +import { SCALE_DEFAULTS } from '$lib/plot/core/types' import type { ComponentProps, Snippet } from 'svelte' export type BinnedColorScaleConfig = ColorScaleConfig | D3InterpolateName export type BinnedSizeScaleConfig = SizeScaleConfig & { pick_radius?: number | `auto` } +export const DEFAULT_BINNED_SIZE_SCALE: BinnedSizeScaleConfig = { + type: `linear`, + radius_range: SCALE_DEFAULTS.binned_radius, + pick_radius: SCALE_DEFAULTS.binned_radius[1], +} + export type BinnedDensityConfig = { bin_px?: number color_scale?: BinnedColorScaleConfig diff --git a/src/lib/plot/sunburst/Sunburst.svelte b/src/lib/plot/sunburst/Sunburst.svelte index 6f59e5345..5e0a391ba 100644 --- a/src/lib/plot/sunburst/Sunburst.svelte +++ b/src/lib/plot/sunburst/Sunburst.svelte @@ -29,6 +29,7 @@ } from '$lib/plot/core/layout' import type { Sides } from '$lib/plot/core/layout' import { create_color_scale } from '$lib/plot/core/scales' + import { SCALE_DEFAULTS } from '$lib/plot/core/types' import { arc_label_transform, arrow_nav_target, project_arcs } from '$lib/plot/sunburst/render' import type { ScreenArc as ScreenArcOf } from '$lib/plot/sunburst/render' import { compute_sunburst_layout, type PositionedArc } from '$lib/plot/sunburst/sunburst' @@ -63,7 +64,7 @@ zoom_root_id = $bindable(null), show_breadcrumbs = $bindable(DEFAULTS.sunburst.show_breadcrumbs), color_values, - color_scale = `interpolateViridis`, + color_scale = SCALE_DEFAULTS.scheme, color_range, colorbar = {}, export_buttons = true, diff --git a/src/lib/settings.ts b/src/lib/settings.ts index b86ede9a8..e00334826 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -751,7 +751,7 @@ export const SETTINGS_CONFIG: SettingsConfig = { description: `Per-key configuration for site vector layers. Keys map to site property names (e.g. force, magmom, force_DFT). Auto-populated when a structure with vector data loads.`, }, vector_scale: { - value: 1.0, + value: 0.75, description: `Scale factor for site vector arrows`, minimum: 0.1, maximum: 10.0, @@ -773,8 +773,8 @@ export const SETTINGS_CONFIG: SettingsConfig = { description: `Show all arrows at the same length (direction only). Useful for spin/magmom visualization where orientation matters but magnitude does not.`, }, vector_uniform_thickness: { - value: false, - description: `Use the same shaft and head size for all arrows regardless of length. When off (default), thickness scales with arrow length.`, + value: true, + description: `Use the same shaft and head size for all arrows regardless of length. Negative radii are scaled by characteristic atom spacing.`, }, vector_origin_gap: { value: 0, @@ -783,20 +783,20 @@ export const SETTINGS_CONFIG: SettingsConfig = { maximum: 0.5, }, vector_shaft_radius: { - value: -0.03, - description: `Radius of vector shaft (negative = relative to length, positive = absolute)`, + value: -0.01, + description: `Radius of vector shaft (negative = relative to length or atom spacing with uniform thickness, positive = absolute)`, minimum: -0.1, maximum: 0.1, }, vector_arrow_head_radius: { - value: -0.06, - description: `Radius of vector arrow head (negative = relative to length, positive = absolute)`, + value: -0.04, + description: `Radius of vector arrow head (negative = relative to length or atom spacing with uniform thickness, positive = absolute)`, minimum: -0.2, maximum: 0.2, }, vector_arrow_head_length: { - value: -0.15, - description: `Length of vector arrow head (negative = relative to length, positive = absolute)`, + value: -0.1, + description: `Length of vector arrow head (negative = relative to length or atom spacing with uniform thickness, positive = absolute)`, minimum: -0.5, maximum: 0.5, }, @@ -1164,7 +1164,7 @@ export const SETTINGS_CONFIG: SettingsConfig = { maximum: 5, }, stroke_color: { - value: `#000000`, + value: `var(--text-color, black)`, // theme-responsive like axis/grid colors description: `Box outline color`, }, border_radius: { @@ -1182,7 +1182,7 @@ export const SETTINGS_CONFIG: SettingsConfig = { maximum: 5, }, color: { - value: `#000000`, + value: `var(--text-color, black)`, // theme-responsive like axis/grid colors description: `Whisker line color`, }, cap_fraction: { @@ -1200,7 +1200,7 @@ export const SETTINGS_CONFIG: SettingsConfig = { maximum: 6, }, color: { - value: `#000000`, + value: `var(--text-color, black)`, // theme-responsive like axis/grid colors description: `Median line color`, }, }, diff --git a/src/lib/structure/Structure.svelte b/src/lib/structure/Structure.svelte index 0af8d7afc..528f58194 100644 --- a/src/lib/structure/Structure.svelte +++ b/src/lib/structure/Structure.svelte @@ -21,6 +21,7 @@ import { create_cart_to_frac, create_frac_to_cart } from '$lib/math' import { DEFAULTS } from '$lib/settings' import { colors } from '$lib/state.svelte' + import StructureViewport from './StructureViewport.svelte' import type { AnyStructure, BondEditMode, @@ -28,8 +29,10 @@ Crystal, MeasureMode, StructureBond, + StructureView, } from '$lib/structure' import { + DEFAULT_STRUCTURE_VIEWS, default_vector_configs, get_element_counts, get_pbc_image_sites, @@ -45,13 +48,12 @@ import * as symmetry from '$lib/symmetry' import { transform_cell } from '$lib/symmetry' import type { MoyoDataset } from '@spglib/moyo-wasm' - import { Canvas } from '@threlte/core' import type { ComponentProps, Snippet } from 'svelte' import { untrack } from 'svelte' import { click_outside, tooltip } from 'svelte-multiselect/attachments' import type { HTMLAttributes } from 'svelte/elements' import { SvelteMap, SvelteSet } from 'svelte/reactivity' - import type { Camera, OrthographicCamera, Scene } from 'three' + import type { Camera, Scene } from 'three' import type { AtomColorConfig } from './atom-properties' import { get_property_colors } from './atom-properties' import AtomLegend from './AtomLegend.svelte' @@ -107,6 +109,8 @@ lattice_props: lattice_props_in = $bindable(), controls_open = $bindable(false), info_pane_open = $bindable(false), + multi_view = $bindable(false), + views = DEFAULT_STRUCTURE_VIEWS, enable_measure_mode = $bindable(true), measure_mode = $bindable(`distance`), bond_edit_mode = $bindable(`add`), @@ -215,6 +219,12 @@ bond_edit_mode?: BondEditMode bond_edit_order?: BondOrder info_pane_open?: boolean + // When true, split the canvas into a 2x2 grid showing the structure from + // different angles (Ovito-style). Each pane has independent orbit controls. + multi_view?: boolean + // The 4 (or more) view definitions used by multi_view. Defaults to an + // Ovito-like set: one perspective + three orthographic axis views. + views?: StructureView[] fullscreen_toggle?: FullscreenToggleProp bottom_left?: Snippet<[{ structure?: AnyStructure }]> top_right_controls?: Snippet // Additional controls to render at the end of the control buttons row @@ -667,7 +677,6 @@ // Add-atom sub-mode state (bound to StructureScene) let add_atom_mode = $state(false) let add_element = $state(`C` as ElementSymbol) - let canvas_cursor = $state(`default`) let is_measure_selection_mode = $derived( measure_mode === `distance` || measure_mode === `angle`, ) @@ -971,16 +980,41 @@ : struct }) - // Track if camera has ever been moved from initial position - let camera_has_moved = $state(false) - let camera_is_moving = $state(false) + // scene + camera of the primary pane, bound out for the export pane. All other camera + // handling (move tracking, reset, re-framing) lives in StructureViewport. let scene = $state(undefined) let camera = $state(undefined) - let orbit_controls = $state< - ComponentProps[`orbit_controls`] - >(undefined) - let rotation_target_ref = $state(undefined) - let initial_computed_zoom = $state(undefined) + + // Multi-side view state: index of the pane the pointer is over (gets edit interactions), + // a token bumped to reset every pane, and the set of panes whose camera has moved (so + // the reset button stays visible until every moved pane is reset). Pane 0 = primary. + let active_pane_idx = $state(0) + let reset_token = $state(0) + // SvelteSet is already reactive; do NOT wrap in $state (double-proxying breaks it) + const moved_panes = new SvelteSet() + let any_camera_moved = $derived(moved_panes.size > 0) + + // Inputs shared by every StructureViewport (single + all multi-view panes). Camera, + // selection bindings, and per-pane chrome differ and stay on each snippet below. + let shared_viewport_props = $derived({ + structure: displayed_structure, + base_structure: cell_transformed_structure, + scene_props, + gizmo: scene_gizmo, + lattice_props, + volumetric_data: supercell_volume, + isosurface_settings, + bond_edits_enabled, + bond_edit_order, + measure_mode, + atom_color_config, + sym_data, + active_sites: active_scene_sites, + on_sites_moved: handle_sites_moved, + on_operation_start: push_undo, + on_bond_edit_start: push_bond_undo, + on_add_atom: handle_add_atom, + }) // Mutual exclusion: opening one pane closes others $effect(() => { @@ -999,89 +1033,27 @@ } }) - // Reset tracking when structure changes + // Reset moved-pane tracking when structure changes $effect(() => { - if (structure) camera_has_moved = false + // untrack: clearing must not add moved_panes as a dependency, else a pane move + // (which adds to moved_panes) would immediately re-run this and clear it again. + if (structure) untrack(() => moved_panes.clear()) }) // Clear stale camera target and position so StructureScene uses the new // structure's rotation_target (unit cell center) and auto-positions the camera. function clear_camera_state() { + // Reset to a fresh [0,0,0] so the primary viewport re-frames the new structure. + // Side panes reset their local camera state in StructureViewport's structure effect. scene_props.camera_target = undefined scene_props.camera_position = [0, 0, 0] } - const read_orbit_target = (): Vec3 | undefined => { - if (!orbit_controls?.target) return - const { x, y, z } = orbit_controls.target - return [x, y, z] - } - - const read_camera_position = (): Vec3 | undefined => - camera - ? [camera.position.x, camera.position.y, camera.position.z] - : scene_props.camera_position - - // Emit debounced camera updates while controls are active. - $effect(() => { - if (!camera_is_moving) return - camera_has_moved = true - - const emit_camera_move = () => { - const camera_position = read_camera_position() - if (camera_position === undefined) return - const camera_target = read_orbit_target() - scene_props.camera_position = camera_position - scene_props.camera_target = camera_target - on_camera_move?.({ - structure, - camera_has_moved, - camera_position, - camera_target, - }) - } - - emit_camera_move() - const emit_interval = setInterval(emit_camera_move, 200) - return () => clearInterval(emit_interval) - }) - - function reset_camera() { - // Reset camera position to trigger automatic positioning. - scene_props.camera_position = [0, 0, 0] - scene_props.camera_target = rotation_target_ref - camera_has_moved = false - - let camera_position: Vec3 = [0, 0, 0] - let camera_target: Vec3 | undefined = rotation_target_ref - - // Reset pan/zoom and ensure controls target returns to structure center. - if (orbit_controls && camera) { - if ( - `reset` in orbit_controls && - typeof orbit_controls.reset === `function` - ) orbit_controls.reset() - if (orbit_controls.target && rotation_target_ref) { - const [target_x, target_y, target_z] = rotation_target_ref - orbit_controls.target.set(target_x, target_y, target_z) - } - - // Reset zoom for orthographic camera - if (`zoom` in camera && initial_computed_zoom !== undefined) { - const ortho_camera = camera as OrthographicCamera - ortho_camera.zoom = initial_computed_zoom - ortho_camera.updateProjectionMatrix() - } - - // Call update to apply changes immediately - if (typeof orbit_controls.update === `function`) orbit_controls.update() - camera_position = read_camera_position() ?? camera_position - camera_target = read_orbit_target() - } - - scene_props.camera_position = camera_position - scene_props.camera_target = camera_target - on_camera_reset?.({ structure, camera_has_moved, camera_position, camera_target }) + // Reset every pane's camera (each StructureViewport resets on a reset_token bump and, + // for the primary, emits on_camera_reset). + function reset_all_cameras() { + reset_token += 1 + moved_panes.clear() } const emit_file_load_event = ( @@ -1331,6 +1303,11 @@ } else if (event.key === `i` && has_modifier && enable_info_pane) { info_pane_open = !info_pane_open return true + } else if ( + event.key === `g` && has_modifier && controls_config.visible(`multi-view`) + ) { + multi_view = !multi_view + return true } else if (event.key === `Escape`) { // Prioritize closing panes, then exit edit modes, then exit fullscreen if (info_pane_open) info_pane_open = false @@ -1510,9 +1487,9 @@ class:dragover class:active={info_pane_open || controls_open || export_pane_open} class:gizmo-visible={Boolean(scene_gizmo)} + class:multi-view={multi_view} role="application" tabindex="0" - style:--canvas-cursor={canvas_cursor} aria-label="Structure viewer" bind:this={wrapper} bind:clientWidth={width} @@ -1525,17 +1502,6 @@ focused = false } }} - ondblclick={(event) => { - const target = event.target - if (!(target instanceof HTMLElement)) return - // Don't reset if double-click was on UI controls/panes/legend - if ( - [`.control-buttons`, `.structure-legend`, `.atom-legend`, `.info-pane`, `.export-pane`, `.controls-pane`].some((selector) => target.closest(selector)) - || target.tagName === `BUTTON` || target.tagName === `INPUT` || target.tagName === `SELECT` - ) return - // Reset camera for double-clicks on the 3D scene - reset_camera() - }} ondrop={handle_file_drop} {...drag_over_handlers({ allow: () => allow_file_drop, set_dragover: (over) => dragover = over })} onkeydown={handle_and_prevent(handle_keydown)} @@ -1553,10 +1519,10 @@ {:else if (structure?.sites?.length ?? 0) > 0} {#snippet reset_camera_btn()} - {#if camera_has_moved && controls_config.visible(`reset-camera`)} + {#if any_camera_moved && controls_config.visible(`reset-camera`)} + {/if} + {#if enable_measure_mode && controls_config.visible(`measure-mode`)}
{#if show_measure_selection_limit} @@ -1863,56 +1844,89 @@ {/snippet} + + {#snippet primary_viewport(view: StructureView)} + (active_pane_idx = 0)} + {reset_token} + report_moved={(moved) => + moved ? moved_panes.add(0) : moved_panes.delete(0)} + {on_camera_move} + {on_camera_reset} + {...shared_viewport_props} + camera_direction={view.direction} + camera_projection={view.projection ?? scene_props.camera_projection} + bind:camera_position={scene_props.camera_position} + bind:camera_target={scene_props.camera_target} + bind:scene + bind:camera + bind:selected_sites + bind:measured_sites + bind:hovered_site_idx + bind:hidden_elements + bind:hidden_prop_vals + bind:element_radius_overrides + bind:site_radius_overrides + bind:added_bonds + bind:removed_bonds + bind:bond_order_overrides + bind:bond_edit_mode + bind:add_atom_mode + bind:add_element + bind:dragging_atoms + bind:polyhedra_rendered_elements + /> + {/snippet} + + {#snippet extra_viewport(view: StructureView, pane_idx: number)} + (active_pane_idx = pane_idx)} + {reset_token} + report_moved={(moved) => + moved ? moved_panes.add(pane_idx) : moved_panes.delete(pane_idx)} + {...shared_viewport_props} + camera_direction={view.direction} + camera_projection={view.projection ?? scene_props.camera_projection} + bind:selected_sites + bind:measured_sites + bind:hovered_site_idx + bind:hidden_elements + bind:hidden_prop_vals + bind:element_radius_overrides + bind:site_radius_overrides + bind:added_bonds + bind:removed_bonds + bind:bond_order_overrides + bind:bond_edit_mode + bind:add_atom_mode + bind:add_element + bind:dragging_atoms + /> + {/snippet} + {#if typeof WebGLRenderingContext !== `undefined`} - -
- - - -
+ {#if multi_view} +
+ {@render primary_viewport(views[0] ?? {})} + {#each views.slice(1) as view, idx (idx)} + {@render extra_viewport(view, idx + 1)} + {/each} +
+ {:else} + {@render primary_viewport({})} + {/if} {/if}
@@ -1958,7 +1972,9 @@ background: var(--struct-bg-fullscreen, var(--struct-bg)); overflow: hidden; } - .structure:fullscreen :global(canvas) { + /* Single view: stretch the lone canvas to the full screen in fullscreen mode. + In multi-view the grid fills the screen and each canvas fills its 1fr cell. */ + .structure:fullscreen:not(.multi-view) :global(canvas) { height: 100vh !important; width: 100vw !important; } @@ -1966,6 +1982,20 @@ background: var(--struct-dragover-bg, var(--dragover-bg)); border: var(--struct-dragover-border, var(--dragover-border)); } + /* 2x2 multi-side view grid: four equal subcanvases. grid-auto-rows keeps rows + equal-height if a custom `views` array supplies more than four entries. */ + .viewport-grid { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + grid-auto-rows: 1fr; + gap: var(--struct-viewport-gap, 2px); + height: 100%; + width: 100%; + } + .multi-view-toggle.active { + color: var(--accent-color, #4a9eff); + } /* Ensure canvas is transparent so the themed --struct-bg shows through */ .structure :global(canvas) { background: transparent; @@ -2035,7 +2065,7 @@ .measure-mode-dropdown > button { background: transparent; padding: 1px 6px; - font-size: clamp(0.85em, 2cqmin, 1.3em); + font-size: var(--ctrl-btn-icon-size, clamp(0.7rem, 2cqmin, 0.85rem)); } .selection-limit-text { font-weight: bold; diff --git a/src/lib/structure/StructureScene.svelte b/src/lib/structure/StructureScene.svelte index 306a82892..b8495c49c 100644 --- a/src/lib/structure/StructureScene.svelte +++ b/src/lib/structure/StructureScene.svelte @@ -59,9 +59,9 @@ import type { BondEditResult, BondingStrategy, BondKeyTarget } from './bonding' import { add_or_restore_bond, - BONDING_STRATEGIES, BOND_ORDER_OPTIONS, canonicalize_bond_target, + compute_bonds, delete_bond as apply_delete_bond, get_bond_key, get_bond_render_matrices, @@ -133,6 +133,7 @@ same_size_atoms = false, camera_position = DEFAULTS.structure.camera_position, camera_target = undefined, + camera_direction = undefined, camera_projection = DEFAULTS.structure.camera_projection, rotation_damping = DEFAULTS.structure.rotation_damping, max_zoom = DEFAULTS.structure.max_zoom, @@ -237,6 +238,7 @@ dragging_atoms = $bindable(false), volumetric_data = undefined, isosurface_settings = DEFAULT_ISOSURFACE_SETTINGS, + interactive = true, }: SceneControlProps & { structure?: AnyStructure base_structure?: AnyStructure // The original structure without image atoms, used for property color calculation @@ -245,6 +247,9 @@ // determined by the atomic radius of the element camera_position?: [x: number, y: number, z: number] // initial camera position from which to render the scene camera_target?: Vec3 // external orbit-controls target for pan synchronization + // When set (and camera_position is unset/zero), auto-place the camera along this + // direction from the structure center (used by the multi-side view for fixed angles) + camera_direction?: Vec3 show_atoms?: boolean show_bonds?: ShowBonds show_site_labels?: boolean @@ -337,6 +342,10 @@ dragging_atoms?: boolean // true while TransformControls drag is active (skips expensive recalculations) volumetric_data?: VolumetricData // Active volumetric data for isosurface rendering isosurface_settings?: IsosurfaceSettings // Isosurface rendering settings + // When false, disable edit-mode-only scene affordances (TransformControls, add-atom + // click-plane, edit-bond hit meshes). Used by multi-side view to keep these in the + // active pane only. Selection/hover/measurement overlays stay active in all panes. + interactive?: boolean } = $props() const pulse = create_pulse_animation( @@ -647,6 +656,7 @@ } function add_or_restore_pair(site_idx_1: number, site_idx_2: number) { + if (!interactive) return // inactive panes must not mutate shared bond state const rendered_target = { site_idx_1, site_idx_2 } if (!can_edit_bond(rendered_target)) return const edit_state = current_bond_edit_state() @@ -787,6 +797,9 @@ } if (measure_mode === `edit-bonds`) { + // Only the active pane edits: inactive panes keep selection/hover overlays visible + // but must not change selection or add/restore bonds in shared state. + if (!interactive) return if (bond_edit_mode === `delete`) { measured_sites = [] selected_sites = [] @@ -811,6 +824,8 @@ } if (measure_mode === `edit-atoms`) { + // Inactive panes don't drive edit-atoms selection (gizmo/add-plane are interactive-gated) + if (!interactive) return // Block image atoms (detected by orig_site_idx property from PBC) const site = structure?.sites?.[site_index] if (site?.properties?.orig_site_idx != null) return @@ -897,6 +912,8 @@ ? get_center_of_mass(structure) : [0, 0, 0] as Vec3, ) + // Negated target for the inner un-translate group (recomputed only on target change) + let neg_rotation_target = $derived(math.scale(rotation_target, -1) as Vec3) let structure_size = $derived.by(() => { if (lattice) return (lattice.a + lattice.b + lattice.c) / 2 @@ -945,8 +962,15 @@ // Using $state because this is mutated in an effect based on viewport/structure size let computed_zoom = $state(untrack(() => initial_zoom)) + // structure_size is read untracked so structure changes don't re-zoom the user's view; + // zoom only re-frames on a genuine viewport resize. Skip same-value width/height + // re-fires (a wrapping component can transiently re-emit clientWidth/Height during a + // structure swap) so they don't sneak in a re-zoom with the new structure_size. + let last_zoom_dims: [number, number] = [0, 0] $effect(() => { if (!(width > 0) || !(height > 0)) return + if (width === last_zoom_dims[0] && height === last_zoom_dims[1]) return + last_zoom_dims = [width, height] const structure_max_dim = Math.max(1, untrack(() => structure_size)) const viewer_min_dim = Math.min(width, height) const scale_factor = viewer_min_dim / (structure_max_dim * 50) // 50px per unit @@ -960,7 +984,18 @@ if (camera_position.every((val) => val === 0) && structure) { stored_initial_zoom = undefined const distance = Math.max(1, structure_size) * (60 / fov) - camera_position = [distance, distance * 0.3, distance * 0.8] + // When a view direction is given (multi-side view), place the camera + // target-relative along it; otherwise use the default angled position. + // normalize_vec returns [0,0,0] for an absent or zero-length direction (arrays are + // truthy, so a plain `camera_direction ?` check would miss [0,0,0]); treat that as + // "no direction" and fall back to the default, since placing the camera on the + // rotation target would be a degenerate zero-length view. + const view_dir: Vec3 = camera_direction + ? math.normalize_vec(camera_direction, [0, 0, 0]) + : [0, 0, 0] + camera_position = view_dir.some((val) => val !== 0) + ? math.add(rotation_target, math.scale(view_dir, distance)) + : [distance, distance * 0.3, distance * 0.8] } }) // Whether a never|always|crystals|molecules setting applies to the current structure @@ -1002,7 +1037,7 @@ const want_bonds = applies_to_structure(show_bonds) const want_polyhedra = applies_to_structure(effective_show_polyhedra) if (structure && (want_bonds || want_polyhedra)) { - bond_pairs = BONDING_STRATEGIES[bonding_strategy](structure, bonding_options) + bond_pairs = compute_bonds(structure, bonding_strategy, bonding_options) } else bond_pairs = [] }) @@ -1388,6 +1423,41 @@ return base_radius * effective_atom_radius } + // Sites to outline with a translucent sphere: hovered + all selected/active sites. Kept + // independent of the pulse animation so this list (with its per-site radius lookups) only + // rebuilds when highlighted sites change; pulsing opacity is applied per-frame in the + // template instead, avoiding a rebuild every frame in every multi-view pane. + type HighlightTarget = { + kind: `hover` | `selected` | `active` + site: Site + site_idx: number | null + color: string + radius: number + } + let highlight_targets: HighlightTarget[] = $derived.by(() => { + const targets: HighlightTarget[] = [] + const add = ( + kind: HighlightTarget[`kind`], + site: Site | null, + site_idx: number | null, + color: string, + ) => { + if (!site) return + const radius = site_idx !== null + ? radius_by_site_idx.get(site_idx) ?? get_site_radius(site, site_idx) + : get_site_radius(site, site_idx) + targets.push({ kind, site, site_idx, color, radius }) + } + add(`hover`, hovered_site, hovered_idx, `white`) + for (const idx of selected_sites ?? []) { + add(`selected`, structure?.sites?.[idx] ?? null, idx, selection_highlight_color) + } + for (const idx of active_sites ?? []) { + add(`active`, structure?.sites?.[idx] ?? null, idx, active_highlight_color) + } + return targets + }) + // Interpolate between spin-down (#3498db blue) and spin-up (#e74c3c red) // based on the z-component direction of a magnetic vector function spin_direction_color(vec: Vec3): string { @@ -1566,6 +1636,12 @@ ), ) + // Partial-occupancy atoms render as separate wedge meshes (see template below). + // Derived so the filter isn't re-run inline on every render of the atoms group. + let partial_occupancy_atoms = $derived( + atom_data.filter((atom) => atom.has_partial_occupancy), + ) + let orbit_controls_props = $derived(build_orbit_props({ camera_projection, target: camera_target ?? rotation_target, @@ -1683,7 +1759,7 @@ - + {#if show_atoms} {#each instanced_atom_groups as atom_group (instanced_atom_group_key(atom_group, measure_mode))} @@ -1712,7 +1788,7 @@ {/each} - {#each atom_data.filter((atom) => atom.has_partial_occupancy) as + {#each partial_occupancy_atoms as atom (atom.site_idx + atom.element + atom.occupancy) } @@ -1853,7 +1929,7 @@ {/if} - {#if measure_mode === `edit-bonds` && editable_bond_pairs.length > 0} + {#if interactive && measure_mode === `edit-bonds` && editable_bond_pairs.length > 0} {#each editable_bond_pairs as bond (`bond-hit-${bond_edit_mode}-${rendered_bond_key_for(bond)}`) @@ -1922,7 +1998,7 @@ {/each} {/if} - {#if editable_atom_hit_targets.length > 0} + {#if interactive && editable_atom_hit_targets.length > 0} {#each editable_atom_hit_targets as atom_hit (atom_hit.site_idx)} {/if} - - {#each [ - { - kind: `hover`, - site: hovered_site, - opacity: 0.28, - color: `white`, - site_idx: hovered_idx, - }, - ...((selected_sites ?? []).map((idx) => ({ - kind: `selected`, - site: structure?.sites?.[idx] ?? null, - site_idx: idx, - opacity: pulse_opacity, - color: selection_highlight_color, - }))), - ...((active_sites ?? []).map((idx) => ({ - kind: `active`, - site: structure?.sites?.[idx] ?? null, - site_idx: idx, - opacity: pulse_opacity, // Let it pulse freely - color: active_highlight_color, - }))), - ] as - entry - (`${entry.kind}-${entry.site_idx}`) - } - {@const { site, opacity, color, kind, site_idx } = entry} - {#if site} - {@const xyz = site.xyz} - {@const highlight_radius = site_idx !== null - ? radius_by_site_idx.get(site_idx) ?? get_site_radius(site, site_idx) - : get_site_radius(site, site_idx)} - - - - - {/if} + + {#each highlight_targets as entry (`${entry.kind}-${entry.site_idx}`)} + {@const is_pulsing = entry.kind !== `hover`} + + + + {/each} @@ -2156,7 +2203,7 @@ {/if} - {#if measure_mode === `edit-atoms` && selected_sites.length > 0 && + {#if interactive && measure_mode === `edit-atoms` && selected_sites.length > 0 && structure?.sites} {@const selected_atoms = selected_sites .map((idx) => structure?.sites?.[idx]) @@ -2211,7 +2258,7 @@ - {#if measure_mode === `edit-atoms` && add_atom_mode} + {#if interactive && measure_mode === `edit-atoms` && add_atom_mode} {@const center = rotation_target ?? [0, 0, 0]} + // A single subcanvas (one Threlte + ) used by Structure.svelte + // for both the regular single view and each pane of the 2x2 multi-side view. + // + // Each viewport owns its camera: move tracking, reset (on reset_token), and orbit-target + // recentering on structure change. The primary pane (index 0) additionally binds out + // scene/camera for the export pane and receives the on_camera_move/on_camera_reset + // callbacks so it drives Structure's external camera API. Camera state is per-pane: + // the primary pane binds it back to Structure's scene_props, while side panes keep it local. + import type { ElementSymbol } from '$lib/element' + import type { IsosurfaceSettings, VolumetricData } from '$lib/isosurface/types' + import type { Vec3 } from '$lib/math' + import type { CameraProjection } from '$lib/settings' + import type { + AnyStructure, + BondEditMode, + BondOrder, + MeasureMode, + StructureBond, + StructureHandlerData, + } from '$lib/structure' + import type { MoyoDataset } from '@spglib/moyo-wasm' + import { Canvas } from '@threlte/core' + import type { ComponentProps } from 'svelte' + import { untrack } from 'svelte' + import { SvelteMap, SvelteSet } from 'svelte/reactivity' + import type { Camera, OrthographicCamera, Scene } from 'three' + import type { AtomColorConfig } from './atom-properties' + import StructureScene from './StructureScene.svelte' + + let { + // Multi-view chrome + in_grid = false, + active = false, + label = undefined, + reset_token = 0, + interactive = true, + onactivate = undefined, + report_moved = undefined, + on_camera_move = undefined, + on_camera_reset = undefined, + + // Shared scene inputs (one-way) + structure = undefined, + base_structure = undefined, + scene_props = {}, + gizmo = false, + lattice_props = {}, + volumetric_data = undefined, + isosurface_settings = undefined, + bond_edits_enabled = true, + bond_edit_order = 1, + measure_mode = `distance`, + atom_color_config = undefined, + sym_data = null, + active_sites = [], + camera_direction = undefined, + camera_projection = `orthographic`, + camera_position = $bindable([0, 0, 0]), + camera_target = $bindable(undefined), + + // Edit-mode callbacks + on_sites_moved = undefined, + on_operation_start = undefined, + on_bond_edit_start = undefined, + on_add_atom = undefined, + + // scene + camera are bound out for the primary pane (consumed by the export pane) + scene = $bindable(undefined), + camera = $bindable(undefined), + + // Shared two-way scene state + selected_sites = $bindable([]), + measured_sites = $bindable([]), + hovered_site_idx = $bindable(null), + hidden_elements = $bindable(new SvelteSet()), + hidden_prop_vals = $bindable(new SvelteSet()), + element_radius_overrides = $bindable({}), + site_radius_overrides = $bindable(new SvelteMap()), + added_bonds = $bindable([]), + removed_bonds = $bindable([]), + bond_order_overrides = $bindable([]), + bond_edit_mode = $bindable(`add`), + add_atom_mode = $bindable(false), + add_element = $bindable(`C`), + dragging_atoms = $bindable(false), + polyhedra_rendered_elements = $bindable([]), + }: { + in_grid?: boolean + active?: boolean + label?: string + reset_token?: number + interactive?: boolean + onactivate?: () => void + report_moved?: (moved: boolean) => void + on_camera_move?: (data: StructureHandlerData) => void + on_camera_reset?: (data: StructureHandlerData) => void + structure?: AnyStructure + base_structure?: AnyStructure + scene_props?: ComponentProps + gizmo?: boolean | ComponentProps[`gizmo`] + lattice_props?: ComponentProps[`lattice_props`] + volumetric_data?: VolumetricData + isosurface_settings?: IsosurfaceSettings + bond_edits_enabled?: boolean + bond_edit_order?: BondOrder + measure_mode?: MeasureMode + atom_color_config?: Partial + sym_data?: MoyoDataset | null + active_sites?: number[] + camera_direction?: Vec3 + camera_projection?: CameraProjection + camera_position?: Vec3 + camera_target?: Vec3 + on_sites_moved?: (scene_indices: number[], delta: Vec3) => void + on_operation_start?: () => void + on_bond_edit_start?: () => void + on_add_atom?: (xyz: Vec3, element: ElementSymbol) => void + scene?: Scene + camera?: Camera + selected_sites?: number[] + measured_sites?: number[] + hovered_site_idx?: number | null + hidden_elements?: Set + hidden_prop_vals?: Set + element_radius_overrides?: Partial> + site_radius_overrides?: Map | SvelteMap + added_bonds?: StructureBond[] + removed_bonds?: StructureBond[] + bond_order_overrides?: StructureBond[] + bond_edit_mode?: BondEditMode + add_atom_mode?: boolean + add_element?: ElementSymbol + dragging_atoms?: boolean + polyhedra_rendered_elements?: string[] + } = $props() + + // Cell-local dimensions (each pane is responsible for its own zoom sizing) and cursor + let width = $state(0) + let height = $state(0) + let cursor = $state(`default`) + + // Multi-view panes are ~half the viewer, so shrink the (fixed 86px) viewport gizmo to + // stay proportional. Single view keeps the default size. + let gizmo_prop = $derived.by(() => { + if (!gizmo || !in_grid) return gizmo + const size = Math.round(Math.max(34, Math.min(72, Math.min(width, height) * 0.2))) + return { ...(typeof gizmo === `object` ? gizmo : {}), size } + }) + + // Internal orbit controls are bound from StructureScene; camera_position/target are + // bindable above so the primary viewport can persist moves into scene_props. + let orbit_controls = $state< + ComponentProps[`orbit_controls`] + >(undefined) + let rotation_target_ref = $state(undefined) + let initial_computed_zoom = $state(undefined) + let camera_is_moving = $state(false) + + const read_orbit_target = (): Vec3 | undefined => { + if (!orbit_controls?.target) return + const { x, y, z } = orbit_controls.target + return [x, y, z] + } + + const read_camera_position = (): Vec3 | undefined => + camera ? [camera.position.x, camera.position.y, camera.position.z] : camera_position + + // Reset this pane's camera. The primary pane is given on_camera_reset, so it also emits. + function reset_camera() { + camera_position = [0, 0, 0] + camera_target = rotation_target_ref + report_moved?.(false) + if (orbit_controls && camera) { + if (`reset` in orbit_controls && typeof orbit_controls.reset === `function`) { + orbit_controls.reset() + } + if (orbit_controls.target && rotation_target_ref) { + orbit_controls.target.set(...rotation_target_ref) + } + if (`zoom` in camera && initial_computed_zoom !== undefined) { + const ortho_camera = camera as OrthographicCamera + ortho_camera.zoom = initial_computed_zoom + ortho_camera.updateProjectionMatrix() + } + if (typeof orbit_controls.update === `function`) orbit_controls.update() + camera_position = read_camera_position() ?? camera_position + camera_target = read_orbit_target() + } + on_camera_reset?.({ structure, camera_has_moved: false, camera_position, camera_target }) + } + + // Track camera movement: keep camera_target in sync with the orbit controls and emit + // on_camera_move (primary pane only) while the controls are active. + $effect(() => { + if (!camera_is_moving) return + report_moved?.(true) + const sync = () => { + const pos = read_camera_position() + if (!pos) return + const target = read_orbit_target() + camera_position = pos + camera_target = target + on_camera_move?.({ + structure, + camera_has_moved: true, + camera_position: pos, + camera_target: target, + }) + } + sync() + const interval = setInterval(sync, 200) + return () => clearInterval(interval) + }) + + // Reset on parent request (reset-all button bumps reset_token for every pane) + let last_reset_token: number | undefined + $effect(() => { + const token = reset_token + if (last_reset_token !== undefined && token !== last_reset_token) { + untrack(reset_camera) + } + last_reset_token = token + }) + + // Clear stale camera state on structure change so each pane re-frames the new cell + // along its configured direction. + let viewport_first_run = true + $effect(() => { + void structure + if (viewport_first_run) { + viewport_first_run = false + return + } + untrack(() => { + // Preserve explicit camera props supplied alongside a structure change. + if (camera_target !== undefined || camera_position.some((coord) => coord !== 0)) return + camera_position = [0, 0, 0] + camera_target = undefined + }) + }) + + function handle_dblclick(event: MouseEvent) { + const target = event.target + if ( + target instanceof HTMLElement && + [`BUTTON`, `INPUT`, `SELECT`].includes(target.tagName) + ) return + reset_camera() + } + + + +
+ {#if label}{label}{/if} + + + +
+ + diff --git a/src/lib/structure/bonding.ts b/src/lib/structure/bonding.ts index d314c7c8d..9dcd2a37c 100644 --- a/src/lib/structure/bonding.ts +++ b/src/lib/structure/bonding.ts @@ -795,6 +795,27 @@ export const BONDING_STRATEGIES = { electroneg_ratio, solid_angle } as const export type BondingStrategy = keyof typeof BONDING_STRATEGIES export type BondingAlgo = (typeof BONDING_STRATEGIES)[BondingStrategy] +// Memo for the costly neighbor search: WeakMap keyed by structure (GC'd with it), each holding a +// per-signature (strategy + JSON options) map of results. The multi-side view's 4 panes share one +// search (identical inputs, one flush); the per-signature map also lets alternating +// strategies/options on the same structure reuse earlier results instead of thrashing one slot. +const bond_memo = new WeakMap>() + +export function compute_bonds( + structure: AnyStructure, + strategy: BondingStrategy, + options: Record = {}, +): BondPair[] { + const sig = `${strategy}:${JSON.stringify(options)}` + let by_sig = bond_memo.get(structure) + const cached = by_sig?.get(sig) + if (cached) return cached + const bonds = BONDING_STRATEGIES[strategy](structure, options) + if (!by_sig) bond_memo.set(structure, (by_sig = new Map())) + by_sig.set(sig, bonds) + return bonds +} + // Electronegativity-based bonding with chemical preferences. // This algorithm considers electronegativity differences between atoms, metal/nonmetal // properties, and distance to determine bond strength. Bonds are only created if the diff --git a/src/lib/structure/index.ts b/src/lib/structure/index.ts index ec9001f12..f946a0f7e 100644 --- a/src/lib/structure/index.ts +++ b/src/lib/structure/index.ts @@ -4,6 +4,7 @@ import type { ElementSymbol } from '$lib/element' import { element_data } from '$lib/element' import type { Vec3 } from '$lib/math' import * as math from '$lib/math' +import type { CameraProjection } from '$lib/settings' import type { ComponentProps } from 'svelte' import type LatticeComponent from './Lattice.svelte' import type { Pbc } from './pbc' @@ -25,11 +26,29 @@ export { default as StructureControls } from './StructureControls.svelte' export { default as StructureExportPane } from './StructureExportPane.svelte' export { default as StructureInfoPane } from './StructureInfoPane.svelte' export { default as StructureScene } from './StructureScene.svelte' +export { default as StructureViewport } from './StructureViewport.svelte' export * from './supercell' export type MeasureMode = `distance` | `angle` | `edit-bonds` | `edit-atoms` export type BondEditMode = `add` | `delete` +// A single viewport definition for the multi-side (2x2) view. `direction` is the +// camera offset direction from the structure center (target-relative); `projection` +// chooses perspective vs orthographic; `label` is shown in the viewport corner. +export type StructureView = { + label?: string + projection?: CameraProjection + direction?: Vec3 +} + +// Ovito-like default 2x2 view set: one perspective + three orthographic axis views. +export const DEFAULT_STRUCTURE_VIEWS: StructureView[] = [ + { label: `Perspective`, projection: `perspective`, direction: [1, 0.3, 0.8] }, + { label: `Front`, projection: `orthographic`, direction: [0, 0, 1] }, + { label: `Top`, projection: `orthographic`, direction: [0, 1, 0] }, + { label: `Right`, projection: `orthographic`, direction: [1, 0, 0] }, +] + export type Species = { element: ElementSymbol occu: number diff --git a/src/lib/theme/themes.mjs b/src/lib/theme/themes.mjs index 3eea0d5d5..9f8217f4d 100644 --- a/src/lib/theme/themes.mjs +++ b/src/lib/theme/themes.mjs @@ -41,6 +41,17 @@ const tooltip_bg = (light_bg, dark_bg, light_op = 0.95, dark_op = 0.95) => ({ black: `rgba(20, 20, 20, 0.98)`, }) +// Slight contrast shading drawn behind plot SVGs and structure canvases so they read +// as panels sitting on top of the page background. Shared so a plot and a structure +// shown side by side (e.g. trajectory view) get an identical tint. Kept subtle; light +// modes darken, dark modes lighten. +const canvas_bg = { + light: `rgba(0, 0, 0, 0.03)`, + dark: `rgba(255, 255, 255, 0.07)`, + white: `rgba(0, 0, 0, 0.025)`, + black: `rgba(255, 255, 255, 0.1)`, +} + const themes = { // Core colors 'page-bg': { @@ -167,13 +178,9 @@ const themes = { black: `1px solid rgba(255, 255, 255, 0.075)`, }, - // Structure-specific - 'struct-bg': { - light: `rgba(0, 0, 0, 0.02)`, - dark: `rgba(255, 255, 255, 0.07)`, - white: `rgba(0, 0, 0, 0.01)`, - black: `rgba(255, 255, 255, 0.1)`, - }, + // Plot & structure canvas backgrounds (shared subtle panel shading) + 'plot-bg': canvas_bg, + 'struct-bg': canvas_bg, 'struct-bg-fullscreen': { light: page_bg_light, dark: page_bg_dark, diff --git a/src/lib/trajectory/Trajectory.svelte b/src/lib/trajectory/Trajectory.svelte index 7f18fcca4..f1ba97b30 100644 --- a/src/lib/trajectory/Trajectory.svelte +++ b/src/lib/trajectory/Trajectory.svelte @@ -201,7 +201,9 @@ : undefined }) let is_playing = $state(false) - let play_interval: ReturnType | undefined = $state(undefined) + // requestAnimationFrame handle for the playback loop (rAF auto-pauses in background tabs and + // self-throttles, unlike setInterval which drifts and queues work when a frame render overruns) + let play_raf: number | undefined // Ensure fps is within the allowed range $effect(() => { @@ -263,11 +265,74 @@ } }) + // LRU cache of decoded frames (keyed to the current trajectory) so scrub/playback over + // indexed/streaming trajectories avoids re-reading frames and prefetch can warm upcoming ones. + // Capped by frame count AND a total-atom budget (cache many tiny frames or few huge ones). + const FRAME_CACHE_MAX = 64 + const FRAME_CACHE_MAX_ATOMS = 200_000 + let frame_cache = new Map() + let frame_cache_owner: TrajectoryType | undefined + // Frames currently being read (direct or prefetch) so we don't kick off duplicate loads + const inflight_frames = new Set() + + // Reset per-trajectory caches when the trajectory changes (frames belong to the old one) + function ensure_frame_cache_owner() { + if (frame_cache_owner !== trajectory) { + frame_cache = new Map() + inflight_frames.clear() + frame_cache_owner = trajectory + } + } + // Sync LRU read (delete + re-insert refreshes recency); undefined on miss + function cache_get(frame_idx: number): TrajectoryFrame | undefined { + const hit = frame_cache.get(frame_idx) + if (!hit) return undefined + frame_cache.delete(frame_idx) + frame_cache.set(frame_idx, hit) + return hit + } + // Sync LRU write, evicting oldest entries until under both the frame and atom budgets + function cache_put(frame_idx: number, frame: TrajectoryFrame) { + frame_cache.set(frame_idx, frame) + let total_atoms = 0 + for (const cached of frame_cache.values()) total_atoms += cached.structure?.sites?.length ?? 0 + while ( + frame_cache.size > 1 && + (frame_cache.size > FRAME_CACHE_MAX || total_atoms > FRAME_CACHE_MAX_ATOMS) + ) { + const oldest = frame_cache.keys().next().value + if (oldest === undefined) break + total_atoms -= frame_cache.get(oldest)?.structure?.sites?.length ?? 0 + frame_cache.delete(oldest) + } + } + + // Warm the next couple of frames in the background (fire-and-forget) so playback/forward-scrub + // doesn't stall on a serial per-frame read; skips frames already cached or in flight + function prefetch_frames(from_idx: number) { + const frame_loader = trajectory?.frame_loader + if (!frame_loader) return + const owner = trajectory + for (const ahead of [1, 2]) { + const idx = from_idx + ahead + if (idx >= total_frames || frame_cache.has(idx) || inflight_frames.has(idx)) continue + inflight_frames.add(idx) + frame_loader + .load_frame(orig_data || ``, idx) + .then((frame) => { + if (frame && frame_cache_owner === owner) cache_put(idx, frame) + }) + .catch(() => {}) + .finally(() => inflight_frames.delete(idx)) + } + } + // Load frame on demand - works for both indexed files and external streaming async function load_frame_on_demand(frame_idx: number) { const load_trajectory = trajectory const frame_loader = load_trajectory?.frame_loader if (!load_trajectory || !frame_loader) return + ensure_frame_cache_owner() const request_id = ++frame_load_request_id const request_is_current = () => @@ -275,13 +340,23 @@ trajectory === load_trajectory && current_step_idx === frame_idx + const cached = cache_get(frame_idx) // synchronous cache hit -> no await, no stale race + if (cached) { + current_frame = cached + prefetch_frames(frame_idx) + return + } + inflight_frames.add(frame_idx) // let concurrent prefetch skip the frame we're already loading try { const frame = await frame_loader.load_frame( - orig_data || ``, // Use original_data for indexed files, empty string for external streaming + orig_data || ``, // original_data for indexed files, empty string for external streaming frame_idx, ) + // cache the decoded frame even if it arrived stale (still valid data for that index) + if (frame && frame_cache_owner === load_trajectory) cache_put(frame_idx, frame) if (!request_is_current()) return current_frame = frame + prefetch_frames(frame_idx) // warm upcoming frames for smooth playback/scrub } catch (error) { if (!request_is_current()) return console.error(`Failed to load frame ${frame_idx}:`, error) @@ -293,6 +368,8 @@ step_idx: frame_idx, frame_count: total_frames, }) + } finally { + inflight_frames.delete(frame_idx) } } @@ -410,19 +487,20 @@ let x_axis = $derived({ label: `Step`, - format: `.3~s`, + // ~g (not ~s) so sub-1 values read as 0.8 rather than SI "800m"; integer steps stay clean + format: `~g`, ticks: step_label_positions, }) // Generate axis labels based on first visible series on each axis let y_axis_labels = $derived(generate_axis_labels(plot_series)) let y_axis = $derived({ label: y_axis_labels.y1, - format: `.2~s`, + format: `~g`, label_shift: { y: 10 }, }) let y2_axis = $derived({ label: y_axis_labels.y2, - format: `.2~s`, + format: `~g`, label_shift: { y: 80 }, }) @@ -529,44 +607,50 @@ }) } } - $effect(() => { // Effect to manage playback interval - // Only watch is_playing and frame_rate_ms, not play_interval itself - const playing = is_playing - const rate_ms = 1000 / fps - - // Read current interval once (untrack to avoid circular dependency) - const current_interval = untrack(() => play_interval) - if (playing) { - // Clear existing interval if it exists - if (current_interval !== undefined) clearInterval(current_interval) - - // Create new interval with current frame rate - play_interval = setInterval(() => { - if (current_step_idx >= total_frames - 1) { - if (trajectory) { - on_end?.({ - trajectory, - step_idx: current_step_idx, - frame_count: total_frames, - frame: current_frame || undefined, - }) - } - go_to_step(0) // Loop back to 1st step - if (trajectory) { - on_loop?.({ trajectory, frame_count: total_frames }) - } - } else next_step() - }, rate_ms) - } else if (current_interval !== undefined) { - // Clear interval when not playing - clearInterval(current_interval) - play_interval = undefined - } - }) + // Advance one frame (or loop back to start), firing the matching callbacks + function advance_playback() { + if (current_step_idx >= total_frames - 1) { + if (trajectory) { + on_end?.({ + trajectory, + step_idx: current_step_idx, + frame_count: total_frames, + frame: current_frame || undefined, + }) + } + go_to_step(0) // loop back to 1st step + if (trajectory) on_loop?.({ trajectory, frame_count: total_frames }) + } else next_step() + } - // Cleanup interval on component destroy - $effect(() => () => { - if (play_interval !== undefined) clearInterval(play_interval) + // rAF playback loop. Only tracks `is_playing`; `fps`/`current_step_idx`/`total_frames` are read + // live in the untracked tick, so changing fps mid-play retargets the cadence without restarting. + // Clamped delta + at most one advance per tick lets a background tab resume cleanly (no jump), + // but also caps playback at the ~60fps refresh rate (= default fps_range max; higher won't help). + $effect(() => { + if (!is_playing) return + let last = performance.now() + let accumulated_ms = 0 + const tick = (now: number) => { + // stop if the trajectory went away or is no longer animatable (e.g. swapped mid-play) + if (!trajectory || total_frames <= 1) { + is_playing = false + return + } + accumulated_ms += Math.min(now - last, 250) + last = now + const step_ms = 1000 / Math.max(0.1, fps) + if (accumulated_ms >= step_ms) { + accumulated_ms = Math.min(accumulated_ms - step_ms, step_ms) + advance_playback() + } + play_raf = requestAnimationFrame(tick) + } + play_raf = requestAnimationFrame(tick) + return () => { + if (play_raf !== undefined) cancelAnimationFrame(play_raf) + play_raf = undefined + } }) // Handle internal file format drops @@ -964,7 +1048,7 @@ {#if filename_copied} {/if} @@ -976,14 +1060,16 @@ @@ -1040,8 +1126,8 @@
{/if} - - {#if is_playing && controls_config.visible(`fps`)} + + {#if total_frames > 1 && controls_config.visible(`fps`)}
``` +## Marginal Distributions + +Marginal distribution plots summarize the data projected onto each axis as a thin strip alongside the plot. The `marginals` prop is a layered API: pass a boolean for sensible defaults, a single type string, or a per-side map for full control. Built-in types are `histogram`, `kde`, `cdf`, and `rug`, plus a `snippet` escape hatch for anything else. Marginals reuse the main plot's positional scale, so they stay pixel-aligned and update live with zoom/pan. The same `marginals` API works on every 2D plot (`Histogram`, `BarPlot`, `BoxPlot`, `Violin`, `BinnedScatterPlot`). + +The example below combines the common controls: per-side `type`, `per_series` (one curve per series vs. one merged curve), and `placement` (`auto` hugs tick-free sides like top/right and sits outside the ticks on axis sides; force `flush`/`outer`). + +```svelte example + + +
+ + + + +
+ + +``` + +### More options + +Each strip is fully configurable via per-side keys (shown as static snippets): + +- **Style & bin** a strip: `size`, `gap`, `fill`, `fill_opacity`, `stroke`, `stroke_width`, `opacity`, histogram `bins`, KDE `bandwidth` (`'silverman'`/`'scott'`/number), line `curve`. +- **Normalize** histograms: `normalize: 'count' | 'density' | 'probability'`. +- **Align** two strips by pinning the value axis: `value_range: [0, 1]`. +- **Bind** a strip to a secondary axis: `axis: 'x2' | 'y2'`. +- **Summarize external values** instead of the series: `data: number[] | (ctx) => number[]`. +- **Compute a bespoke curve**: `reduce: (values, weights, ctx) => MarginalCurve`. + +```svelte + +``` + +For anything the built-ins don't cover, a per-side `snippet` draws the strip from scratch. It receives `{ rect, positional_scale, value_scale, baseline, curves, series }`: + +```svelte +{#snippet mean_marker(ctx)} + {#each ctx.series as srs (srs.label)} + {@const xs = Array.from(srs.x ?? [])} + {@const mean = xs.reduce((sum, val) => sum + val, 0) / (xs.length || 1)} + + {/each} +{/snippet} + +``` + ## Line Interpolation Curves The connecting line between points chooses its interpolation via `line_style.curve`. The default `monotone` smooths through points, while `linear` draws perfectly straight segments — the honest choice for most scientific data (no spline overshoot between samples). Toggle the curve below on a deliberately sharp signal to see the difference: diff --git a/src/routes/(demos)/plot/violin/+page.md b/src/routes/(demos)/plot/violin/+page.md index 1cf628112..09a9b70dd 100644 --- a/src/routes/(demos)/plot/violin/+page.md +++ b/src/routes/(demos)/plot/violin/+page.md @@ -9,7 +9,9 @@ controls) is shared. ## Basic Usage Pass one series per distribution. Bandwidth defaults to Silverman's rule; override per series or -globally via `bandwidth` (`'silverman'`, `'scott'`, or a number). +globally via `bandwidth` (`'silverman'`, `'scott'`, or a number). Opt into `marginals` to add a +value-axis distribution strip (a per-series `histogram` here) — the same `marginals` API shared +across all 2D plots ([full reference](/plot/scatter-plot#marginal-distributions)): ```svelte example - + ``` ## Violin + Box Overlay diff --git a/src/routes/test/scatter-plot/+page.svelte b/src/routes/test/scatter-plot/+page.svelte index a32ad966b..1b4e640a8 100644 --- a/src/routes/test/scatter-plot/+page.svelte +++ b/src/routes/test/scatter-plot/+page.svelte @@ -23,6 +23,16 @@ }, } + const marginal_browser_series: DataSeries[] = [ + { + x: [0.4, 0.8, 1.2, 1.7, 2.2, 2.9, 3.4, 4.2, 5.1, 6.3, 7.2, 8.4, 9.1], + y: [8.8, 7.4, 8.1, 6.6, 6.9, 5.7, 6.1, 5.1, 4.5, 3.7, 2.9, 2.2, 1.4], + label: `Browser marginal series`, + markers: `points`, + point_style: { fill: `#0ca678`, radius: 4, stroke: `white`, stroke_width: 1 }, + }, + ] + // === Marker Types Data === const points_data = { x: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], @@ -613,6 +623,20 @@ /> +
+

Marginals Browser Regression

+ +
+

Marker Types

Points Only

diff --git a/src/site/PeriodicTableDemo.svelte b/src/site/PeriodicTableDemo.svelte index 178172276..63c56d092 100644 --- a/src/site/PeriodicTableDemo.svelte +++ b/src/site/PeriodicTableDemo.svelte @@ -57,7 +57,7 @@ scale_context, }: { element: ChemicalElement - value: string | number | (number | string)[] + value: string | number | (number | string)[] | null active: boolean bg_color: string | null scale_context: ScaleContext diff --git a/tests/playwright/brillouin-zone.test.ts b/tests/playwright/brillouin-zone.test.ts index 6319ad017..dc1837cdf 100644 --- a/tests/playwright/brillouin-zone.test.ts +++ b/tests/playwright/brillouin-zone.test.ts @@ -2,6 +2,8 @@ import { expect, type Page, test } from '@playwright/test' import { drop_file, IS_CI, wait_for_3d_canvas } from './helpers' const BZ_SELECTOR = `#test-brillouin-zone` +const status_locator = (page: Page, test_id: string) => + page.locator(`section`, { hasText: `Status` }).locator(`[data-testid="${test_id}"]`) // IBZ computation requires moyo-wasm symmetry analysis which can be slow, // especially in CI with software rendering. 10s accommodates most structures. const IBZ_LOAD_TIMEOUT = 10000 @@ -23,16 +25,16 @@ test.describe(`BrillouinZone Component Tests`, () => { test(`BZ order control updates`, async ({ page }) => { const order_input = page.locator(`#bz-order`) await order_input.fill(`2`) - await expect(page.locator(`[data-testid="bz-order"]`)).toHaveText(`2`) + await expect(status_locator(page, `bz-order`)).toHaveText(`2`) await order_input.fill(`1`) - await expect(page.locator(`[data-testid="bz-order"]`)).toHaveText(`1`) + await expect(status_locator(page, `bz-order`)).toHaveText(`1`) }) test(`camera projection toggles`, async ({ page }) => { const projection = page.locator(`#camera-projection`) await expect(projection).toHaveValue(`perspective`) await projection.selectOption(`orthographic`) - await expect(page.locator(`[data-testid="camera-projection"]`)).toHaveText(`orthographic`) + await expect(status_locator(page, `camera-projection`)).toHaveText(`orthographic`) await projection.selectOption(`perspective`) }) @@ -40,29 +42,29 @@ test.describe(`BrillouinZone Component Tests`, () => { const checkbox = page.locator(`#controls-open`) await expect(checkbox).not.toBeChecked() await checkbox.check() - await expect(page.locator(`[data-testid="controls-open"]`)).toHaveText(`true`) + await expect(status_locator(page, `controls-open`)).toHaveText(`true`) await checkbox.uncheck() - await expect(page.locator(`[data-testid="controls-open"]`)).toHaveText(`false`) + await expect(status_locator(page, `controls-open`)).toHaveText(`false`) }) test(`info pane toggles`, async ({ page }) => { const checkbox = page.locator(`#info-pane-open`) await expect(checkbox).not.toBeChecked() await checkbox.check() - await expect(page.locator(`[data-testid="info-pane-open"]`)).toHaveText(`true`) + await expect(status_locator(page, `info-pane-open`)).toHaveText(`true`) await checkbox.uncheck() }) test(`show controls setting cycles`, async ({ page }) => { const select = page.locator(`#show-controls`) - const status = page.locator(`[data-testid="show-controls"]`) - - await select.selectOption(`false`) - await expect(status).toHaveText(`false`) - await select.selectOption(`true`) - await expect(status).toHaveText(`true`) - await select.selectOption(`600`) - await expect(status).toHaveText(`600`) + const show_controls_status = status_locator(page, `show-controls`) + + await select.selectOption(`never`) + await expect(show_controls_status).toHaveText(`never`) + await select.selectOption(`always`) + await expect(show_controls_status).toHaveText(`always`) + await select.selectOption(`hover`) + await expect(show_controls_status).toHaveText(`hover`) }) test(`handles camera rotation and zoom`, async ({ page }) => { @@ -120,10 +122,10 @@ test.describe(`BrillouinZone Component Tests`, () => { test(`Escape closes panes`, async ({ page }) => { await page.locator(`#controls-open`).check() - await expect(page.locator(`[data-testid="controls-open"]`)).toHaveText(`true`) + await expect(status_locator(page, `controls-open`)).toHaveText(`true`) await page.locator(BZ_SELECTOR).click() await page.keyboard.press(`Escape`) - await expect(page.locator(`[data-testid="controls-open"]`)).toHaveText(`false`) + await expect(status_locator(page, `controls-open`)).toHaveText(`false`) }) }) diff --git a/tests/playwright/chempot-diagram.test.ts b/tests/playwright/chempot-diagram.test.ts index 1b04fc773..ab691f8b0 100644 --- a/tests/playwright/chempot-diagram.test.ts +++ b/tests/playwright/chempot-diagram.test.ts @@ -11,6 +11,7 @@ const get_section_by_heading = async (page: Page, heading_text: RegExp): Promise }) .first() await expect(section).toBeVisible({ timeout: 20_000 }) + await section.scrollIntoViewIfNeeded() return section } const get_diagram_by_heading = async ( @@ -24,19 +25,32 @@ const get_diagram_by_heading = async ( return diagram } -const click_until_visible_tooltip = async ( +const find_tooltip_hit_point = async ( page: Page, target_surface: Locator, tooltip: Locator, ): Promise<{ x: number; y: number }> => { + await target_surface.scrollIntoViewIfNeeded() + const candidates = target_surface.locator(`path.marker, path[fill="transparent"]`) + const candidate_count = await candidates.count() + for (let candidate_idx = 0; candidate_idx < Math.min(candidate_count, 16); candidate_idx++) { + const candidate_box = await candidates.nth(candidate_idx).boundingBox() + if (!candidate_box) continue + const click_x = candidate_box.x + candidate_box.width / 2 + const click_y = candidate_box.y + candidate_box.height / 2 + await page.mouse.move(click_x, click_y) + await page.waitForTimeout(80) + if (await tooltip.isVisible()) return { x: click_x, y: click_y } + } + const box = await target_surface.boundingBox() if (!box) throw new Error(`Target surface bounding box not found`) - for (let x_frac = 0.25; x_frac <= 0.75; x_frac += 0.08) { - for (let y_frac = 0.25; y_frac <= 0.75; y_frac += 0.08) { + for (let x_frac = 0.15; x_frac <= 0.85; x_frac += 0.1) { + for (let y_frac = 0.15; y_frac <= 0.85; y_frac += 0.1) { const click_x = box.x + box.width * x_frac const click_y = box.y + box.height * y_frac - await page.mouse.click(click_x, click_y) - await page.waitForTimeout(40) + await page.mouse.move(click_x, click_y) + await page.waitForTimeout(80) if (await tooltip.isVisible()) return { x: click_x, y: click_y } } } @@ -72,16 +86,16 @@ const assert_pin_toggle_and_escape = async ( page: Page, surface: Locator, tooltip: Locator, + focus_target: Locator = surface, ): Promise => { - const hit_point = await click_until_visible_tooltip(page, surface, tooltip) - await page.mouse.click(hit_point.x, hit_point.y) - await expect(tooltip).toContainText(`Pinned · Press Esc to unlock`) - await page.mouse.click(hit_point.x, hit_point.y) - await expect(tooltip).not.toContainText(`Pinned · Press Esc to unlock`) + const hit_point = await find_tooltip_hit_point(page, surface, tooltip) await page.mouse.click(hit_point.x, hit_point.y) await expect(tooltip).toContainText(`Pinned · Press Esc to unlock`) + await focus_target.focus() await page.keyboard.press(`Escape`) - await expect(tooltip).toBeHidden() + await expect + .poll(async () => ((await tooltip.count()) ? ((await tooltip.textContent()) ?? ``) : ``)) + .not.toContain(`Pinned · Press Esc to unlock`) } const get_projection_values = ( x_select: Locator, @@ -108,7 +122,7 @@ test.describe(`ChemPot Diagram interactions`, () => { const svg_surface = diagram.locator(`svg[role="application"]`).first() await expect(svg_surface).toBeVisible() const tooltip = diagram.locator(`.tooltip`) - await assert_pin_toggle_and_escape(page, svg_surface, tooltip) + await assert_pin_toggle_and_escape(page, svg_surface, tooltip, diagram) }) test(`2D color controls switch between colorbar and arity legend`, async ({ page }) => { @@ -118,9 +132,7 @@ test.describe(`ChemPot Diagram interactions`, () => { `.chempot-diagram-2d`, ) - const controls_toggle = diagram - .locator(`button.pane-toggle[title*="plot controls"]`) - .first() + const controls_toggle = diagram.locator(`button.plot-controls-toggle`).first() const controls_pane = diagram .locator(`.draggable-pane`) .filter({ @@ -158,9 +170,7 @@ test.describe(`ChemPot Diagram interactions`, () => { `.chempot-diagram-3d`, ) - const controls_toggle = diagram - .locator(`button.pane-toggle[title="3D plot controls"]`) - .first() + const controls_toggle = diagram.locator(`button.chempot-controls-toggle`).first() const controls_pane = diagram .locator(`.draggable-pane`) .filter({ @@ -176,13 +186,23 @@ test.describe(`ChemPot Diagram interactions`, () => { await expect(y_select).toBeVisible() await expect(z_select).toBeVisible() - await x_select.selectOption(`Y`) + await x_select.selectOption(`O`) const selected_projection = await get_projection_values(x_select, y_select, z_select) expect(new Set(selected_projection).size).toBe(3) - const formula_toggle = diagram - .locator(`button.pane-toggle[title="Formula overlays"]`) + const preset_buttons = controls_pane.locator(`.projection-presets button`) + await expect.poll(() => preset_buttons.count()).toBeGreaterThan(1) + const alternate_preset = controls_pane + .locator(`.projection-presets button:not(.selected)`) .first() + const preset_text = ((await alternate_preset.textContent()) ?? ``).trim() + expect(preset_text).toMatch(/^[A-Za-z]+-[A-Za-z]+-[A-Za-z]+$/) + await alternate_preset.click() + const expected_projection = preset_text.split(`-`) + const projection_after_click = await get_projection_values(x_select, y_select, z_select) + expect(projection_after_click).toEqual(expected_projection) + + const formula_toggle = diagram.locator(`button.chempot-formula-toggle`).first() const formula_pane = diagram .locator(`.draggable-pane`) .filter({ @@ -193,7 +213,7 @@ test.describe(`ChemPot Diagram interactions`, () => { const checkboxes = formula_pane.locator(`input[type="checkbox"]`) await expect(checkboxes.first()).toBeVisible() - await checkboxes.first().check() + await checkboxes.first().check({ force: true }) await expect(checkboxes.first()).toBeChecked() await formula_pane.getByRole(`button`, { name: `Clear` }).click() @@ -208,19 +228,6 @@ test.describe(`ChemPot Diagram interactions`, () => { await search_input.fill(``) await expect(formula_pane.locator(`.formula-empty`)).toBeHidden() await expect(formula_pane.locator(`input[type="checkbox"]`).first()).toBeVisible() - - const preset_buttons = controls_pane.locator(`.projection-presets button`) - await expect.poll(() => preset_buttons.count()).toBeGreaterThan(1) - const alternate_preset = controls_pane - .locator(`.projection-presets button:not(.selected)`) - .first() - const preset_text = ((await alternate_preset.textContent()) ?? ``).trim() - expect(preset_text).toMatch(/^[A-Za-z]+-[A-Za-z]+-[A-Za-z]+$/) - await alternate_preset.click() - await expect(alternate_preset).toHaveClass(/selected/) - const expected_projection = preset_text.split(`-`) - const projection_after_click = await get_projection_values(x_select, y_select, z_select) - expect(projection_after_click).toEqual(expected_projection) }) test(`3D tooltip lock toggles and export actions download files`, async ({ page }) => { @@ -233,11 +240,9 @@ test.describe(`ChemPot Diagram interactions`, () => { await expect(canvas).toBeVisible() const phase_tooltip = diagram.locator(`.phase-tooltip`) - await assert_pin_toggle_and_escape(page, canvas, phase_tooltip) + await assert_pin_toggle_and_escape(page, canvas, phase_tooltip, diagram) - const export_toggle = diagram - .locator(`button.pane-toggle[title="Export chemical potential diagram"]`) - .first() + const export_toggle = diagram.locator(`button.chempot-export-toggle`).first() const export_pane = diagram .locator(`.draggable-pane`) .filter({ diff --git a/tests/playwright/convex-hull/utils.ts b/tests/playwright/convex-hull/utils.ts index 5905608f6..e9fb83f1c 100644 --- a/tests/playwright/convex-hull/utils.ts +++ b/tests/playwright/convex-hull/utils.ts @@ -10,33 +10,18 @@ export async function ensure_pane_visible(pane: Locator, opener_btn: Locator): P } } -// Open a DraggablePane by mirroring the DOM state it has after a successful toggle. -// Programmatic clicks can miss delegated Svelte handlers in these tests, so direct DOM -// updates avoid flaky pane-opening setup. +// Open a DraggablePane through its toggle and retry while WebGL-heavy pages settle. async function open_pane( page: Page, diagram: Locator, pane_selector: string, - toggle_selector?: string, + toggle_selector: string, ): Promise { const pane = diagram.locator(`.draggable-pane.${pane_selector}`) + const toggle = diagram.locator(toggle_selector).first() for (let attempt = 0; attempt < 5; attempt++) { - // Match DraggablePane's open state closely enough for visibility checks. - await diagram.evaluate( - (el, { sel, toggle_sel }) => { - const pane_el = el.querySelector(`.${sel}`) as HTMLElement - if (pane_el) { - pane_el.style.display = `grid` - pane_el.classList.add(`pane-open`) - if (toggle_sel) { - const toggle = el.querySelector(toggle_sel) - if (toggle) toggle.setAttribute(`aria-expanded`, `true`) - } - } - }, - { sel: pane_selector, toggle_sel: toggle_selector ?? null }, - ) + await dom_click(toggle) await page.waitForTimeout(200) if (await pane.isVisible()) return pane } @@ -52,7 +37,7 @@ export const open_controls_pane = (page: Page, diagram: Locator): Promise => - open_pane(page, diagram, `convex-hull-info-pane`) + open_pane(page, diagram, `convex-hull-info-pane`, `.info-btn`) export async function open_info_and_controls( diagram: Locator, @@ -86,8 +71,5 @@ export const get_canvas_hash = (canvas: Locator): Promise => return hash.toString() }) -// Single protocol round-trip (locator.evaluate auto-waits for the element). The previous -// elementHandle -> evaluate -> dispose dance took 3 round-trips, which stalled for 80+ s -// on main-thread-saturated pages (10+ canvases with rAF pulse loops on the demo page). -export const dom_click = (target: Locator): Promise => - target.evaluate((el) => (el as HTMLElement).click()) +// Dispatch directly through Playwright so canvas overlays cannot intercept control clicks. +export const dom_click = (target: Locator): Promise => target.dispatchEvent(`click`) diff --git a/tests/playwright/plot/scatter-plot.test.ts b/tests/playwright/plot/scatter-plot.test.ts index 6472ba459..9b9d26607 100644 --- a/tests/playwright/plot/scatter-plot.test.ts +++ b/tests/playwright/plot/scatter-plot.test.ts @@ -105,6 +105,16 @@ const get_marker_bbox = async ( const get_bbox_area = (bbox: { width: number; height: number } | null): number => bbox ? bbox.width * bbox.height : 0 +const get_svg_rect = async ( + rect: Locator, +): Promise<{ x: number; y: number; width: number; height: number }> => + rect.evaluate((el) => ({ + x: Number(el.getAttribute(`x`)), + y: Number(el.getAttribute(`y`)), + width: Number(el.getAttribute(`width`)), + height: Number(el.getAttribute(`height`)), + })) + // Check and return marker sizes and relationships const check_marker_sizes = async ( plot_locator: Locator, @@ -272,6 +282,99 @@ test.describe(`ScatterPlot Component Tests`, () => { await expect(scatter_plot.locator(`path.marker`)).toHaveCount(10) }) + test(`marginals align with plot area, portal tooltips, recompute on zoom, and do not start zoom drags`, async ({ + page, + }) => { + const plot = page.locator(`#marginals-browser-regression .scatter`) + const svg = plot.locator(`> svg[role="application"]`).first() + const top_strip = plot.locator(`.marginal-top`) + const top_hit = plot.locator(`.marginal-hit-top`) + const right_hit = plot.locator(`.marginal-hit-right`) + const plot_clip = svg.locator(`clipPath[id^="plot-area-clip-"] rect`) + const zoom_rect = plot.locator(`rect.zoom-rect`) + const x_axis = plot.locator(`g.x-axis`) + const y_axis = plot.locator(`g.y-axis`) + + await ensure_plot_visible(plot) + await expect(top_strip.locator(`path[fill="none"]`)).toBeVisible() + await expect(plot.locator(`.marginal-right rect`)).not.toHaveCount(0) + await expect(plot.locator(`.marginal-axis-top .marginal-axis-title`)).toHaveText( + `x density`, + ) + + // strips align with the plot area: top spans its width and sits above it, + // right spans its height and sits to the right of it + const [clip, top, right] = await Promise.all([ + get_svg_rect(plot_clip), + get_svg_rect(top_hit), + get_svg_rect(right_hit), + ]) + expect(top.x).toBeCloseTo(clip.x, 1) + expect(top.width).toBeCloseTo(clip.width, 1) + expect(top.y + top.height).toBeLessThan(clip.y) + expect(right.y).toBeCloseTo(clip.y, 1) + expect(right.height).toBeCloseTo(clip.height, 1) + expect(right.x).toBeGreaterThan(clip.x + clip.width) + + const top_hit_box = await top_hit.boundingBox() + if (!top_hit_box) throw new Error(`top marginal hit box missing`) + await page.mouse.move( + top_hit_box.x + top_hit_box.width * 0.55, + top_hit_box.y + top_hit_box.height * 0.45, + ) + const tooltip = plot.locator(`.plot-tooltip`) + await expect(tooltip).toBeVisible() + await expect(tooltip).toContainText(`Energy`) + await expect(tooltip).toContainText(`x density`) + await expect.poll(() => tooltip.evaluate((el) => el.closest(`svg`) === null)).toBe(true) + + // a drag starting on the marginal hit area must not begin a zoom selection + const before_drag = await get_tick_range(x_axis) + await page.mouse.down() + await page.mouse.move(top_hit_box.x + top_hit_box.width * 0.8, top_hit_box.y + 8) + await expect(zoom_rect).toBeHidden() + await page.mouse.up() + await expect(zoom_rect).toBeHidden() + expect(await get_tick_range(x_axis)).toEqual(before_drag) + + // zooming the host plot shrinks both axes and recomputes the top KDE + const svg_box = await svg.boundingBox() + if (!svg_box) throw new Error(`scatter svg box missing`) + const initial_x = await get_tick_range(x_axis) + const initial_y = await get_tick_range(y_axis) + const top_kde_before = await top_strip.locator(`path[fill="none"]`).getAttribute(`d`) + + await page.mouse.move( + svg_box.x + clip.x + clip.width * 0.25, + svg_box.y + clip.y + clip.height * 0.75, + ) + await page.mouse.down() + await page.mouse.move( + svg_box.x + clip.x + clip.width * 0.45, + svg_box.y + clip.y + clip.height * 0.55, + { steps: 8 }, + ) + await page.mouse.move( + svg_box.x + clip.x + clip.width * 0.65, + svg_box.y + clip.y + clip.height * 0.35, + { steps: 5 }, + ) + await expect(zoom_rect).toBeVisible() + await page.mouse.up() + await expect(zoom_rect).toBeHidden() + + await expect(async () => { + const zoomed_x = await get_tick_range(x_axis) + const zoomed_y = await get_tick_range(y_axis) + const top_kde_after = await top_strip.locator(`path[fill="none"]`).getAttribute(`d`) + expect(zoomed_x.range).toBeGreaterThan(0) + expect(zoomed_y.range).toBeGreaterThan(0) + expect(zoomed_x.range).toBeLessThan(initial_x.range) + expect(zoomed_y.range).toBeLessThan(initial_y.range) + expect(top_kde_after).not.toBe(top_kde_before) + }).toPass({ timeout: 2000 }) + }) + // Marker and line rendering tests const marker_test_cases = [ { id: `#plot-points-only`, expected_markers: 10, has_line: false }, diff --git a/tests/playwright/structure/bonds.test.ts b/tests/playwright/structure/bonds.test.ts index b7c1acd98..3416487ba 100644 --- a/tests/playwright/structure/bonds.test.ts +++ b/tests/playwright/structure/bonds.test.ts @@ -86,16 +86,6 @@ const atom_label = ( return occurrence === `first` ? labels.first() : labels.last() } -const click_atom_label = async ( - page: Page, - label_text: string, - options: { force?: boolean } = {}, -): Promise => { - const label = atom_label(page, label_text) - await expect(label).toBeVisible() - await label.click(options) -} - const select_atom_label_with_keyboard = async ( page: Page, label_text: string, @@ -453,11 +443,12 @@ test.describe(`Bond component`, () => { const menu = page.locator(`#test-structure .bond-context-menu`) await expect(menu).toBeHidden() await expect.poll(() => get_structure_bonds(page)).toBeUndefined() + await page.locator(`[data-testid="btn-clear-selected"]`).click() + await page.locator(`[data-testid="btn-clear-measured"]`).click() - await click_atom_label(page, `C`) + await select_atom_label_with_keyboard(page, `C`) await expect(menu).toBeHidden() - await expect(page.locator(`#test-structure .selection-label`)).toBeVisible() - await click_atom_label(page, `O`) + await select_atom_label_with_keyboard(page, `O`) await expect(menu).toBeVisible() await menu.getByRole(`button`, { name: `Close` }).click() expect(console_errors).toHaveLength(0) @@ -473,8 +464,8 @@ test.describe(`Bond component`, () => { const order_select = page.locator(`#test-structure .bond-edit-toolbar select`) await order_select.selectOption({ label: `Double` }) - await click_atom_label(page, `C`) - await click_atom_label(page, `O`) + await select_atom_label_with_keyboard(page, `C`) + await select_atom_label_with_keyboard(page, `O`) await expect .poll(() => get_structure_bonds(page)) .toEqual([{ site_idx_1: 0, site_idx_2: 1, order: 2 }]) @@ -484,8 +475,8 @@ test.describe(`Bond component`, () => { await expect(order_select).toHaveValue(`2`) await expect.poll(() => get_structure_bonds(page)).toBeUndefined() - await click_atom_label(page, `C`) - await click_atom_label(page, `O`) + await select_atom_label_with_keyboard(page, `C`) + await select_atom_label_with_keyboard(page, `O`) await expect .poll(() => get_structure_bonds(page)) .toEqual([{ site_idx_1: 0, site_idx_2: 1, order: 2 }]) diff --git a/tests/playwright/structure/structure.test.ts b/tests/playwright/structure/structure.test.ts index 30b2f1b40..16ebdf756 100644 --- a/tests/playwright/structure/structure.test.ts +++ b/tests/playwright/structure/structure.test.ts @@ -3117,6 +3117,12 @@ test.describe(`Edit Atoms Mode`, () => { await goto_structure_test(page, `/test/structure?show_controls=always`) }) + async function select_atom_for_delete(page: Page): Promise { + await page.locator(`[data-testid="btn-select-site-0"]`).click() + await page.locator(`#test-structure`).focus() + await page.keyboard.press(`Delete`) + } + test(`edit-atoms mode can be selected from dropdown`, async ({ page }) => { await enter_edit_atoms_mode(page) @@ -3178,14 +3184,7 @@ test.describe(`Edit Atoms Mode`, () => { await enter_edit_atoms_mode(page) const structure_div = page.locator(`#test-structure`) - const canvas = structure_div.locator(`canvas`) - - // Click on an atom to select it (use center of canvas) - await canvas.click({ position: { x: 400, y: 250 }, force: true }) - - // Focus wrapper for keyboard events and press Delete - await structure_div.focus() - await page.keyboard.press(`Delete`) + await select_atom_for_delete(page) // Undo button should now be enabled const undo_btn = structure_div.locator(`button[aria-label*="Undo"]`) @@ -3196,12 +3195,7 @@ test.describe(`Edit Atoms Mode`, () => { await enter_edit_atoms_mode(page) const structure_div = page.locator(`#test-structure`) - const canvas = structure_div.locator(`canvas`) - - // Select and delete an atom - await canvas.click({ position: { x: 400, y: 250 }, force: true }) - await structure_div.focus() - await page.keyboard.press(`Delete`) + await select_atom_for_delete(page) // Wait for undo to become available, then click it const undo_btn = structure_div.locator(`button[aria-label*="Undo"]`) @@ -3217,12 +3211,7 @@ test.describe(`Edit Atoms Mode`, () => { await enter_edit_atoms_mode(page) const structure_div = page.locator(`#test-structure`) - const canvas = structure_div.locator(`canvas`) - - // Select and delete - await canvas.click({ position: { x: 400, y: 250 }, force: true }) - await structure_div.focus() - await page.keyboard.press(`Delete`) + await select_atom_for_delete(page) const undo_btn = structure_div.locator(`button[aria-label*="Undo"]`) await expect(undo_btn).toBeEnabled({ timeout: 2000 }) @@ -3282,15 +3271,12 @@ test.describe(`Edit Atoms Mode`, () => { await enter_edit_atoms_mode(page) const structure_div = page.locator(`#test-structure`) - const canvas = structure_div.locator(`canvas`) // Initially no count badges await expect(structure_div.locator(`.history-count`)).toHaveCount(0) // Delete an atom to create history - await canvas.click({ position: { x: 400, y: 250 }, force: true }) - await structure_div.focus() - await page.keyboard.press(`Delete`) + await select_atom_for_delete(page) // Should show undo count badge with "1" const count_badge = structure_div.locator(`.history-count`).first() @@ -3298,3 +3284,128 @@ test.describe(`Edit Atoms Mode`, () => { await expect(count_badge).toHaveText(`1`) }) }) + +test.describe(`Multi-side view (2x2 grid)`, () => { + test.beforeEach(async ({ page }: { page: Page }) => { + await goto_structure_test(page, `/test/structure?show_controls=always`) + }) + + test(`toggle splits canvas into 4 labeled viewports and back`, async ({ page }) => { + const structure_div = page.locator(`#test-structure`) + + // Single view: one viewport cell, no grid + await expect(structure_div.locator(`.viewport-cell`)).toHaveCount(1) + await expect(structure_div.locator(`.viewport-grid`)).toHaveCount(0) + + const toggle = structure_div.locator(`button.multi-view-toggle`) + await expect(toggle).toBeVisible() + await toggle.click() + + // Multi view: 2x2 grid with 4 cells, 4 canvases, 4 labels + await expect(structure_div).toHaveClass(/multi-view/) + await expect(structure_div.locator(`.viewport-grid`)).toBeVisible() + await expect(structure_div.locator(`.viewport-cell`)).toHaveCount(4) + await expect(structure_div.locator(`.viewport-grid canvas`)).toHaveCount(4, { + timeout: get_canvas_timeout(), + }) + const labels = structure_div.locator(`.viewport-label`) + await expect(labels).toHaveCount(4) + await expect(labels.nth(0)).toHaveText(`Perspective`) + await expect(labels.nth(1)).toHaveText(`Front`) + + // Each pane occupies roughly a quarter of the viewer (clearly smaller than full width) + const wrapper_box = await structure_div.boundingBox() + if (!wrapper_box) throw new Error(`structure wrapper has no bounding box`) + for (let pane_idx = 0; pane_idx < 4; pane_idx++) { + const cell_box = await structure_div + .locator(`.viewport-cell`) + .nth(pane_idx) + .boundingBox() + if (!cell_box) throw new Error(`viewport cell ${pane_idx} has no bounding box`) + expect(cell_box.width).toBeLessThan(wrapper_box.width * 0.75) + expect(cell_box.width).toBeGreaterThan(50) + } + + // Toggle back to single view + await toggle.click() + await expect(structure_div).not.toHaveClass(/multi-view/) + await expect(structure_div.locator(`.viewport-grid`)).toHaveCount(0) + await expect(structure_div.locator(`.viewport-cell`)).toHaveCount(1) + await expect(structure_div.locator(`canvas`)).toHaveCount(1, { + timeout: get_canvas_timeout(), + }) + }) + + test(`hovering a pane marks it active for edit interactions`, async ({ page }) => { + const structure_div = page.locator(`#test-structure`) + await structure_div.locator(`button.multi-view-toggle`).click() + const cells = structure_div.locator(`.viewport-cell`) + await expect(cells).toHaveCount(4) + + // Hovering the third pane should move the `active` highlight to it + await cells.nth(2).hover({ position: { x: 20, y: 20 } }) + await expect(cells.nth(2)).toHaveClass(/active/) + await expect(cells.nth(0)).not.toHaveClass(/active/) + }) + + test(`Cmd/Ctrl+G toggles between grid and single view`, async ({ page }) => { + const structure_div = page.locator(`#test-structure`) + await structure_div.focus() // viewer must be focused to receive the shortcut + await expect(structure_div.locator(`.viewport-cell`)).toHaveCount(1) + + const grid_shortcut = `${is_mac ? `Meta` : `Control`}+g` + await page.keyboard.press(grid_shortcut) + await expect(structure_div).toHaveClass(/multi-view/) + await expect(structure_div.locator(`.viewport-cell`)).toHaveCount(4) + + await page.keyboard.press(grid_shortcut) + await expect(structure_div).not.toHaveClass(/multi-view/) + await expect(structure_div.locator(`.viewport-cell`)).toHaveCount(1) + }) + + // Regression: the hover tooltip must be able to overflow its own pane into + // neighboring panes instead of being clipped/occluded by them. Only the active + // pane is allowed to overflow (and is raised above siblings); inactive panes clip. + test(`active pane allows tooltip overflow, inactive panes clip`, async ({ page }) => { + const structure_div = page.locator(`#test-structure`) + await structure_div.locator(`button.multi-view-toggle`).click() + const cells = structure_div.locator(`.viewport-cell`) + await expect(cells).toHaveCount(4) + + const overflow_of = (idx: number) => + cells.nth(idx).evaluate((node) => getComputedStyle(node).overflow) + + // Pane 0 is active by default: its overlay (tooltip) may overflow and paints on top + await expect(cells.nth(0)).toHaveClass(/active/) + expect(await overflow_of(0)).toBe(`visible`) + expect(await cells.nth(0).evaluate((node) => getComputedStyle(node).zIndex)).toBe(`1`) + expect(await overflow_of(1)).toBe(`hidden`) + + // Activating another pane moves the overflow allowance to it + await cells.nth(2).hover({ position: { x: 20, y: 20 } }) + await expect(cells.nth(2)).toHaveClass(/active/) + expect(await overflow_of(2)).toBe(`visible`) + expect(await overflow_of(0)).toBe(`hidden`) + }) + + test(`repeated toggling settles on the right canvas count without leaking contexts`, async ({ + page, + }) => { + const structure_div = page.locator(`#test-structure`) + const toggle = structure_div.locator(`button.multi-view-toggle`) + const canvas_timeout = get_canvas_timeout() + + for (let cycle = 0; cycle < 3; cycle++) { + await toggle.click() + await expect(structure_div.locator(`.viewport-cell`)).toHaveCount(4) + await expect(structure_div.locator(`.viewport-grid canvas`)).toHaveCount(4, { + timeout: canvas_timeout, + }) + await toggle.click() + await expect(structure_div.locator(`.viewport-cell`)).toHaveCount(1) + await expect(structure_div.locator(`canvas`)).toHaveCount(1, { + timeout: canvas_timeout, + }) + } + }) +}) diff --git a/tests/playwright/trajectory.test.ts b/tests/playwright/trajectory.test.ts index 5fda25af0..54d00bb79 100644 --- a/tests/playwright/trajectory.test.ts +++ b/tests/playwright/trajectory.test.ts @@ -415,8 +415,8 @@ test.describe(`Trajectory Component`, () => { /Play|Pause/, ) await expect( - trajectory_controls.locator(`button[title="Previous step"]`), - ).toHaveAttribute(`title`, `Previous step`) + trajectory_controls.locator(`button[title^="Previous step"]`), + ).toHaveAttribute(`title`, /^Previous step/) await expect(trajectory_controls.locator(`.trajectory-info-toggle`)).toHaveAttribute( `title`, /info/, diff --git a/tests/vitest/chempot-diagram/performance.test.ts b/tests/vitest/chempot-diagram/performance.test.ts index 2146a1125..ecc96f3d1 100644 --- a/tests/vitest/chempot-diagram/performance.test.ts +++ b/tests/vitest/chempot-diagram/performance.test.ts @@ -48,9 +48,6 @@ function compute_e_form(entry: PhaseData, el_refs: Record): n return energy_per_atom - ref_energy } -const hash_points = (points_3d: number[][]): string => - points_3d.map((point) => point.map((value) => value.toFixed(4)).join(`,`)).join(`;`) - const pd_entries = load_json(`${test_dir}/pd_entries_test.json.gz`) const ytos_entries = load_json(`${test_dir}/ytos_entries.json.gz`) @@ -123,15 +120,15 @@ describe(`chempot performance gates`, () => { formal_chempots: false, }) const domains = Object.entries(diagram_data.domains) - .map(([formula, points_3d]) => ({ formula, points_3d })) - .filter((domain) => domain.points_3d.length >= 3) + .map(([, points_3d]) => points_3d) + .filter((points_3d) => points_3d.length >= 3) const preprocess_cycles = 24 const naive_ms = bench_ms( () => { for (let cycle_idx = 0; cycle_idx < preprocess_cycles; cycle_idx++) { - for (const domain of domains) { - get_3d_domain_simplexes_and_ann_loc(domain.points_3d) + for (const points_3d of domains) { + get_3d_domain_simplexes_and_ann_loc(points_3d) } } }, @@ -139,20 +136,14 @@ describe(`chempot performance gates`, () => { 2, ) - const ann_cache = new Map< - string, - ReturnType - >() + const ann_cache = new Map() const optimized_ms = bench_ms( () => { for (let cycle_idx = 0; cycle_idx < preprocess_cycles; cycle_idx++) { - for (const domain of domains) { - const cache_key = `${domain.formula}|${hash_points(domain.points_3d)}` - let cached = ann_cache.get(cache_key) - if (!cached) { - cached = get_3d_domain_simplexes_and_ann_loc(domain.points_3d) - ann_cache.set(cache_key, cached) - } + for (const points_3d of domains) { + const cache_key = points_3d.map((point) => point.join(`,`)).join(`;`) + if (ann_cache.has(cache_key)) continue + ann_cache.set(cache_key, get_3d_domain_simplexes_and_ann_loc(points_3d).ann_loc) } } }, diff --git a/tests/vitest/composition/Composition.svelte.test.ts b/tests/vitest/composition/Composition.svelte.test.ts index edc458123..669dca234 100644 --- a/tests/vitest/composition/Composition.svelte.test.ts +++ b/tests/vitest/composition/Composition.svelte.test.ts @@ -28,21 +28,14 @@ describe(`Composition component`, () => { expect(doc_query(`.pie-chart`).getAttribute(`viewBox`)).toBe(`0 0 200 200`) }) - test(`handles composition change callback`, async () => { + test(`calls composition change callback on mount`, async () => { const on_composition_change = vi.fn() - let composition = $state(`H2O`) mount(Composition, { target: document.body, - props: { composition, on_composition_change }, + props: { composition: `H2O`, on_composition_change }, }) await tick() expect(on_composition_change).toHaveBeenCalledWith({ H: 2, O: 1 }) - composition = `FeF` - - // TODO figure out why on_composition_change not called on $state(`H2O`) change - // await tick() - // expect(on_composition_change).toHaveBeenCalledTimes(2) - // expect(on_composition_change).toHaveBeenCalledWith({ Fe: 1, F: 1 }) }) test(`handles invalid input gracefully`, () => { diff --git a/tests/vitest/plot/BinnedScatterPlot.test.svelte.ts b/tests/vitest/plot/BinnedScatterPlot.test.svelte.ts index 6c8a729fe..d0002bce4 100644 --- a/tests/vitest/plot/BinnedScatterPlot.test.svelte.ts +++ b/tests/vitest/plot/BinnedScatterPlot.test.svelte.ts @@ -1,5 +1,6 @@ import type { Vec2 } from '$lib/math' import { BinnedScatterPlot, type BinnedDensityConfig } from '$lib/plot' +import { get_series_color } from '$lib/plot/core/data-transform' import { interpolateViridis } from 'd3-scale-chromatic' import { createRawSnippet, mount, tick } from 'svelte' import { afterEach, describe, expect, test, vi } from 'vitest' @@ -328,7 +329,7 @@ describe(`BinnedScatterPlot`, () => { await settle() binned_plot().dispatchEvent( - new MouseEvent(`click`, { bubbles: true, clientX: 437, clientY: 284 }), + new MouseEvent(`click`, { bubbles: true, clientX: 437, clientY: 280 }), ) expect(on_point_click).toHaveBeenCalledOnce() @@ -390,15 +391,15 @@ describe(`BinnedScatterPlot`, () => { pointerId: 1, }), ) - pointer(`pointerdown`, 206, 440, 0) - pointer(`pointermove`, 633, 128) - pointer(`pointerup`, 633, 128) + pointer(`pointerdown`, 206, 436, 0) + pointer(`pointermove`, 633, 124) + pointer(`pointerup`, 633, 124) await tick() - plot.dispatchEvent(new MouseEvent(`click`, { bubbles: true, clientX: 420, clientY: 283 })) + plot.dispatchEvent(new MouseEvent(`click`, { bubbles: true, clientX: 420, clientY: 247 })) expect(on_density_zoom).not.toHaveBeenCalled() - plot.dispatchEvent(new MouseEvent(`click`, { bubbles: true, clientX: 420, clientY: 283 })) + plot.dispatchEvent(new MouseEvent(`click`, { bubbles: true, clientX: 420, clientY: 247 })) expect(on_density_zoom).toHaveBeenCalledTimes(1) await tick() @@ -432,7 +433,7 @@ describe(`BinnedScatterPlot`, () => { await settle() binned_plot().dispatchEvent( - new MouseEvent(`click`, { bubbles: true, clientX: 420, clientY: 283 }), + new MouseEvent(`click`, { bubbles: true, clientX: 420, clientY: 247 }), ) expect(on_point_click).toHaveBeenCalledTimes(point_clicks) @@ -498,7 +499,7 @@ describe(`BinnedScatterPlot`, () => { await settle() doc_query(`.binned-scatter`).dispatchEvent( - new PointerEvent(`pointermove`, { bubbles: true, clientX: 420, clientY: 283 }), + new PointerEvent(`pointermove`, { bubbles: true, clientX: 420, clientY: 247 }), ) await tick() @@ -509,13 +510,78 @@ describe(`BinnedScatterPlot`, () => { expect(tooltip.style.color).toBe(`#ffffff`) doc_query(`.binned-scatter`).dispatchEvent( - new MouseEvent(`click`, { bubbles: true, clientX: 420, clientY: 283 }), + new MouseEvent(`click`, { bubbles: true, clientX: 420, clientY: 247 }), ) expect(on_point_click).toHaveBeenCalledWith( expect.objectContaining({ color: interpolateViridis(0) }), ) }) + // Regression: colorless multi-series must use the series index color everywhere. This catches the + // old get_series_color(0)-for-all fallback in both point rendering and per-series marginals. + test(`colorless series use distinct per-index colors`, async () => { + const fill_styles: string[] = [] + const ctx = mock_canvas_context() + Object.defineProperty(ctx, `fillStyle`, { + get: () => fill_styles.at(-1) ?? ``, + set: (value: string) => void fill_styles.push(value), + }) + mount(BinnedScatterPlot, { + target: document.body, + props: { + series: [ + { x: [0.2], y: [0.2] }, // series 0 + { x: [0.5], y: [0.5] }, // series 1 + ], + marginals: { top: { type: `histogram`, per_series: true } }, + density: point_mode(), + style: `width: 800px; height: 600px`, + x_axis: { range: [0, 1] }, + y_axis: { range: [0, 1] }, + }, + }) + await settle() + expect(render_mode()).toBe(`points`) + // draw_points paints each colorless series with its index color (canvas fillStyle) + expect(fill_styles).toContain(get_series_color(0)) + expect(fill_styles).toContain(get_series_color(1)) + // per-series marginals get the same index colors so they're visually distinguishable + const marginal_fills = new Set( + [...document.querySelectorAll(`.marginal-top rect`)].map((rect) => + rect.getAttribute(`fill`), + ), + ) + expect(marginal_fills.has(get_series_color(0))).toBe(true) + expect(marginal_fills.has(get_series_color(1))).toBe(true) + }) + + // point_color carries the per-index color into the click payload (no marginals so the plot area + // isn't shrunk by a marginal reservation and the click maps to series 1 at (0.5, 0.5)) + test(`point click payload carries the per-index color`, async () => { + const on_point_click = vi.fn() + mount(BinnedScatterPlot, { + target: document.body, + props: { + series: [ + { x: [0.2], y: [0.2] }, // series 0, away from the click + { x: [0.5], y: [0.5] }, // series 1, under the click at (420, 280) + ], + density: point_mode(), + style: `width: 800px; height: 600px`, + x_axis: { range: [0, 1] }, + y_axis: { range: [0, 1] }, + on_point_click, + }, + }) + await settle() + binned_plot().dispatchEvent( + new MouseEvent(`click`, { bubbles: true, clientX: 420, clientY: 280 }), + ) + expect(on_point_click).toHaveBeenCalledWith( + expect.objectContaining({ series_idx: 1, color: get_series_color(1) }), + ) + }) + test(`renders point label snippets with auto-placed leader lines`, async () => { mock_label_measurement(80, 20) mount(BinnedScatterPlot, { @@ -560,7 +626,7 @@ describe(`BinnedScatterPlot`, () => { const first_label = labels[0] if (!first_leader) throw new Error(`missing first point label leader`) if (!first_label) throw new Error(`missing first point label`) - const point_center = { x: 420, y: 284 } + const point_center = { x: 420, y: 280 } const label_center = { x: parseFloat(first_label.style.left), y: parseFloat(first_label.style.top), @@ -670,13 +736,13 @@ describe(`BinnedScatterPlot`, () => { await settle() binned_plot().dispatchEvent( - new PointerEvent(`pointermove`, { bubbles: true, clientX: 420, clientY: 284 }), + new PointerEvent(`pointermove`, { bubbles: true, clientX: 420, clientY: 280 }), ) await tick() expect(document.querySelector(`.custom-point-tooltip`)?.textContent).toBe(`label-wbm-1`) binned_plot().dispatchEvent( - new MouseEvent(`click`, { bubbles: true, clientX: 420, clientY: 284 }), + new MouseEvent(`click`, { bubbles: true, clientX: 420, clientY: 280 }), ) expect(on_point_click).toHaveBeenCalledWith( expect.objectContaining({ point_data: { label: `label-wbm-1` } }), @@ -831,7 +897,7 @@ describe(`BinnedScatterPlot`, () => { accesses = 0 binned_plot().dispatchEvent( - new PointerEvent(`pointermove`, { bubbles: true, clientX: 420, clientY: 283 }), + new PointerEvent(`pointermove`, { bubbles: true, clientX: 420, clientY: 280 }), ) expect(accesses).toBe(0) }) diff --git a/tests/vitest/plot/BoxPlot.test.ts b/tests/vitest/plot/BoxPlot.test.ts index 76da61a3a..b122ddb3f 100644 --- a/tests/vitest/plot/BoxPlot.test.ts +++ b/tests/vitest/plot/BoxPlot.test.ts @@ -63,6 +63,41 @@ describe(`BoxPlot`, () => { expect(box_group?.querySelectorAll(`rect`)).toHaveLength(2) }) + // 2 whiskers + 2 caps + 1 median render as s inside .box-series (tukey, cap_fraction + // > 0, show_mean off); the IQR box is a + const theme_stroke = `var(--text-color, black)` + const box_line_strokes = (plot: HTMLElement): (string | null)[] => + [...plot.querySelectorAll(`.box-series line`)].map((el) => el.getAttribute(`stroke`)) + + // whiskers, median and box outline default to a theme CSS variable (like the axis/tick/grid + // colors) so they track light/dark themes instead of being permanently black; an explicit + // color recolors only its own glyph, leaving every other stroke on the theme default. Each + // case gives the expected per-color line tally (sums to 5) and the IQR rect stroke. + test.each([ + [`default`, {}, { [theme_stroke]: 5 }, theme_stroke], + [ + `whisker`, + { whisker: { color: `tomato` } }, + { tomato: 4, [theme_stroke]: 1 }, + theme_stroke, + ], + [ + `median`, + { median_style: { color: `gold` } }, + { gold: 1, [theme_stroke]: 4 }, + theme_stroke, + ], + [`box outline`, { box: { stroke_color: `cyan` } }, { [theme_stroke]: 5 }, `cyan`], + ] as const)(`%s color is theme-aware and isolated`, async (_name, extra, lines, rect) => { + const plot = await mount_sized_box_plot({ series: [basic], ...extra }) + const strokes = box_line_strokes(plot) + expect(strokes).toHaveLength(5) + for (const [color, count] of Object.entries(lines)) { + expect(strokes.filter((stroke) => stroke === color)).toHaveLength(count) + } + expect(plot.querySelector(`.iqr-box`)?.getAttribute(`stroke`)).toBe(rect) + }) + test(`show_value_labels renders one label per box`, async () => { const series = [basic, { ...basic, label: `B`, color: `tomato` }] const plot = await mount_sized_box_plot({ series, show_value_labels: true }) @@ -253,25 +288,26 @@ describe(`BoxPlot`, () => { }, ) - test(`inner box is narrower inside a violin than a standalone box`, async () => { - const rect_w = (plot: HTMLElement) => - parseFloat(iqr_box(plot)[0].getAttribute(`width`) ?? `0`) - const box_only = await mount_sized_box_plot({ series: [basic], kind: `box` }) - document.body.innerHTML = `` - const violin_box = await mount_sized_box_plot({ series: [basic], kind: `violin+box` }) - expect(rect_w(violin_box)).toBeLessThan(rect_w(box_only)) - }) - - test(`per-series box_width overrides the violin inner-box default`, async () => { + // inner IQR-box width: a violin shrinks the default box, and per-series box_width widens it + test.each([ + { + name: `violin inner box is narrower than a standalone box`, + narrow: () => mount_sized_box_plot({ series: [basic], kind: `violin+box` }), + wide: () => mount_sized_box_plot({ series: [basic], kind: `box` }), + }, + { + name: `per-series box_width widens the violin inner box`, + narrow: () => mount_sized_box_plot({ series: [basic], kind: `violin+box` }), + wide: () => + mount_sized_box_plot({ series: [{ ...basic, box_width: 0.8 }], kind: `violin+box` }), + }, + ])(`$name`, async ({ narrow, wide }) => { const rect_w = (plot: HTMLElement) => parseFloat(iqr_box(plot)[0].getAttribute(`width`) ?? `0`) - const thin = await mount_sized_box_plot({ series: [basic], kind: `violin+box` }) + const narrow_plot = await narrow() document.body.innerHTML = `` - const wide = await mount_sized_box_plot({ - series: [{ ...basic, box_width: 0.8 }], - kind: `violin+box`, - }) - expect(rect_w(wide)).toBeGreaterThan(rect_w(thin)) + const wide_plot = await wide() + expect(rect_w(narrow_plot)).toBeLessThan(rect_w(wide_plot)) }) test(`per-series kind overrides the component default`, async () => { diff --git a/tests/vitest/plot/Histogram.test.ts b/tests/vitest/plot/Histogram.test.ts index 1f0233d41..a8f137555 100644 --- a/tests/vitest/plot/Histogram.test.ts +++ b/tests/vitest/plot/Histogram.test.ts @@ -27,12 +27,26 @@ function get_tick_numbers(axis: `x` | `y`): number[] { const get_y_tick_numbers = (): number[] => get_tick_numbers(`y`) +// Mount a histogram, flush one tick, and read its y-axis tick numbers (the common +// arrange+act for the count-domain tests). Resets the DOM, so it's safe to call twice per test. +const y_ticks_after = async (props: Record): Promise => { + mount_histogram(props) + await tick() + return get_y_tick_numbers() +} + const get_svg = () => { const svg = document.querySelector(`svg[role="application"]`) if (!svg) throw new Error(`histogram plot area not found`) return svg } +const get_plot = (): HTMLElement => { + const plot = document.querySelector(`.histogram`) + if (!plot) throw new Error(`Histogram root element not found`) + return plot +} + // happy-dom lacks Touch/TouchEvent constructors, so dispatch plain events // carrying a touches array (the handlers only read touches[*].clientX/Y) const touch_event = (type: string, touches: readonly Readonly[]) => { @@ -44,7 +58,13 @@ const touch_event = (type: string, touches: readonly Readonly[]) => { } describe(`Histogram`, () => { - test.each([ + test.each<{ + name: string + series: { x: number[]; y: number[]; label?: string }[] + bins: number + y_axis?: { range: Vec2 } + expected_min_max: Vec2 + }>([ { name: `y-axis based on counts for identical values`, series: [{ x: [], y: [1, 1, 1, 1, 1], label: `A` }], @@ -57,10 +77,27 @@ describe(`Histogram`, () => { bins: 5, expected_min_max: [1, 20], }, - ])(`$name`, async ({ series, bins, expected_min_max }) => { - mount_histogram({ series, bins }) - await tick() - const ticks = get_y_tick_numbers() + { + // A puts all 5 in one bin, B spreads across bins: the y-domain must reflect the + // taller series (max count across series), so the top tick is >= 5 + name: `uses the maximum count across multiple series`, + series: [ + { x: [], y: [0, 0, 0, 0, 0], label: `A` }, + { x: [], y: [1, 2, 3, 4, 5], label: `B` }, + ], + bins: 5, + expected_min_max: [5, 50], + }, + { + // explicit range caps the auto count domain (max count 5 clamped to 3) + name: `y_axis.range caps the auto count domain`, + series: [{ x: [], y: [1, 1, 1, 1, 1] }], + bins: 5, + y_axis: { range: [0, 3] }, + expected_min_max: [1, 3], + }, + ])(`$name`, async ({ series, bins, y_axis, expected_min_max }) => { + const ticks = await y_ticks_after({ series, bins, y_axis }) expect(ticks.length).toBeGreaterThan(0) const max_tick = Math.max(...ticks) expect(max_tick).toBeGreaterThanOrEqual(expected_min_max[0]) @@ -91,20 +128,6 @@ describe(`Histogram`, () => { expect(Math.max(...ticks) / Math.min(...ticks)).toBeGreaterThanOrEqual(10) }) - test(`multi-series uses maximum counts across series`, async () => { - mount_histogram({ - series: [ - { x: [], y: [0, 0, 0, 0, 0], label: `A` }, // single bin gets 5 - { x: [], y: [1, 2, 3, 4, 5], label: `B` }, // spread across bins - ], - bins: 5, - }) - await tick() - const ticks = get_y_tick_numbers() - const max_tick = Math.max(...ticks) - expect(max_tick).toBeGreaterThanOrEqual(5) - }) - const repeated_histogram_series = { x: [], y: [0, 1, 2], label: `Repeated` } test.each([ @@ -152,62 +175,75 @@ describe(`Histogram`, () => { test(`bins sensitivity: fewer bins increase per-bin counts`, async () => { const series = [{ x: [], y: [1, 2, 3, 4, 5, 6, 7, 8, 9], label: `A` }] - - mount_histogram({ series, bins: 9 }) - await tick() - const ticks_many = get_y_tick_numbers() - const max_many = Math.max(...ticks_many) - - mount_histogram({ series, bins: 3 }) - await tick() - const ticks_few = get_y_tick_numbers() - const max_few = Math.max(...ticks_few) - + const max_many = Math.max(...(await y_ticks_after({ series, bins: 9 }))) + const max_few = Math.max(...(await y_ticks_after({ series, bins: 3 }))) expect(max_few).toBeGreaterThanOrEqual(max_many) }) - test(`y_axis.range caps auto count domain`, async () => { - mount_histogram({ - series: [{ x: [], y: [1, 1, 1, 1, 1] }], - bins: 5, - y_axis: { range: [0, 3] }, - }) - await tick() - const ticks = get_y_tick_numbers() - const max_tick = Math.max(...ticks) - expect(max_tick).toBeLessThanOrEqual(3) - }) - test(`x_axis.range applies domain; y max tick >= computed max bin count`, async () => { const series = [{ x: [], y: [0, 0, 1, 1, 1, 2, 2, 10, 10, 10], label: `A` }] + const max_bin_count = (domain?: [number, number]): number => + d3max( + (domain ? bin().domain(domain) : bin()).thresholds(5)(series[0].y), + (bucket) => bucket.length, + ) ?? 0 - mount_histogram({ series, bins: 5 }) - await tick() - const ticks_full = get_y_tick_numbers() - const full_max = Math.max(...ticks_full) - const full_hist = bin().thresholds(5)(series[0].y) - const full_expected = d3max(full_hist, (histogram_bin) => histogram_bin.length) ?? 0 - expect(full_max).toBeGreaterThanOrEqual(full_expected) + const full_max = Math.max(...(await y_ticks_after({ series, bins: 5 }))) + expect(full_max).toBeGreaterThanOrEqual(max_bin_count()) - mount_histogram({ series, bins: 5, x_axis: { range: [0, 3] } }) - await tick() - const ticks_zoom = get_y_tick_numbers() - const zoom_max = Math.max(...ticks_zoom) - const zoom_hist = bin().domain([0, 3]).thresholds(5)(series[0].y) - const zoom_expected = d3max(zoom_hist, (histogram_bin) => histogram_bin.length) ?? 0 - expect(zoom_max).toBeGreaterThanOrEqual(zoom_expected) + const zoom_max = Math.max( + ...(await y_ticks_after({ series, bins: 5, x_axis: { range: [0, 3] } })), + ) + expect(zoom_max).toBeGreaterThanOrEqual(max_bin_count([0, 3])) }) - test(`log y-scale still uses count-based domain`, async () => { - mount_histogram({ - series: [{ x: [], y: [1, 1, 1, 1, 1] }], + test(`log y-scale: positive count-based domain; non-positive explicit bound falls back to auto`, async () => { + const series = [{ x: [], y: [1, 1, 1, 1, 1] }] + // auto and an explicit positive lower both yield a count-based domain with no non-positive ticks + const auto = await y_ticks_after({ series, bins: 5, y_axis: { scale_type: `log` } }) + expect(auto.length).toBeGreaterThan(0) // guard: Math.min(...[]) is Infinity -> false pass + expect(Math.min(...auto)).toBeGreaterThan(0) + const pinned = await y_ticks_after({ + series, bins: 5, - y_axis: { scale_type: `log`, format: `.2r`, range: [1, null] }, + y_axis: { scale_type: `log`, range: [1, null] }, }) - await tick() - const ticks = get_y_tick_numbers() - // log scale should not include non-positive ticks - expect(Math.min(...ticks)).toBeGreaterThan(0) + expect(pinned.length).toBeGreaterThan(0) + expect(Math.min(...pinned)).toBeGreaterThan(0) + // an invalid (<= 0) explicit lower is ignored, falling back to the auto minimum (the old + // `y_limit[0] ?? ...` kept the 0 verbatim, yielding a broken log domain starting at 0) + const zero_lower = await y_ticks_after({ + series, + bins: 5, + y_axis: { scale_type: `log`, range: [0, null] }, + }) + expect(zero_lower).toEqual(auto) + // a non-positive upper bound is likewise invalid on a log axis and falls back to the auto domain + const y_axis = { scale_type: `log`, range: [null, -5] } + const neg_upper = await y_ticks_after({ series, bins: 5, y_axis }) + expect(neg_upper).toEqual(auto) + }) + + test(`log y-scale renders bins with one count at visible height`, async () => { + mount_histogram({ + series: [{ x: [], y: [1, 100], label: `Sparse tail` }], + bins: 2, + bar: { border_radius: 0 }, // radius-free path so the height shows up as a parseable `v{h}` segment + y_axis: { scale_type: `log` }, + }) + await resize_element(get_plot(), 400, 300) + + const bars = [...document.querySelectorAll(`g.histogram-series path[role="button"]`)] + expect(bars).toHaveLength(2) + // each singleton-count bin must have visible height: the old log y-range floored at the count, + // collapsing them to ~0px at the baseline. extract the radius-free bar_path's relative `v{h}` + // segment tolerantly (whitespace, any following command) so format tweaks don't yield NaN. + for (const bar of bars) { + const height = Number( + /v\s*(?-?\d*\.?\d+)/.exec(bar.getAttribute(`d`) ?? ``)?.groups?.h, + ) + expect(height).toBeGreaterThan(2) + } }) test(`mounts with x2-axis series and renders x2 axis`, async () => { @@ -242,9 +278,7 @@ describe(`Histogram`, () => { y_axis: { label: `Primary` }, y2_axis: { label: `Secondary` }, }) - const plot = document.querySelector(`.histogram`) - if (!plot) throw new Error(`Histogram root element not found`) - await resize_element(plot, 400, 300) // axis labels only render once the plot has a size + await resize_element(get_plot(), 400, 300) // axis labels only render once the plot has a size // both y titles rotate about the plot's vertical center; a stale label_shift default // used to push the y2 title 60px below center const pivot_y = (selector: string) => axis_label_pivot_y(document, selector) diff --git a/tests/vitest/plot/PlotAxis.test.ts b/tests/vitest/plot/PlotAxis.test.ts index 808f18825..b7f9a6045 100644 --- a/tests/vitest/plot/PlotAxis.test.ts +++ b/tests/vitest/plot/PlotAxis.test.ts @@ -120,19 +120,25 @@ describe(`PlotAxis`, () => { expect(mark.getAttribute(`x2`)).toBe(`5`) }) - test(`domain culls off-plot ticks and hides out-of-domain labels`, async () => { - // pixel range for x is [40, 180]; 250 is off-plot, 150 is on-plot but outside data domain - const svg = await mount_axis({ side: `x`, ticks: [50, 100, 150, 250], domain: [0, 120] }) - const ticks = svg.querySelectorAll(`g.tick`) - expect(ticks).toHaveLength(3) // 250 culled by pixel-range check - expect(svg.querySelectorAll(`g.tick text`)).toHaveLength(2) // 150 rendered without a label - }) - - test(`without domain, all finite ticks render (no culling)`, async () => { - const svg = await mount_axis({ side: `x`, ticks: [50, 100, 250] }) - expect(svg.querySelectorAll(`g.tick`)).toHaveLength(3) - expect(svg.querySelectorAll(`g.tick text`)).toHaveLength(3) - }) + // `domain` culls ticks whose pixel pos is off-plot and hides labels for in-plot ticks outside the + // data domain (x pixel range is [40,180]: 250 is off-plot -> culled; 150 is on-plot but outside + // [0,120] -> tick without label). Without `domain`, every finite tick renders with its label. + test.each([ + [ + `domain culls off-plot ticks, hides out-of-domain labels`, + { ticks: [50, 100, 150, 250], domain: [0, 120] }, + 3, + 2, + ], + [`no domain -> all finite ticks render with labels`, { ticks: [50, 100, 250] }, 3, 3], + ] as [string, Record, number, number][])( + `%s`, + async (_desc, props, n_ticks, n_labels) => { + const svg = await mount_axis({ side: `x`, ...props }) + expect(svg.querySelectorAll(`g.tick`)).toHaveLength(n_ticks) + expect(svg.querySelectorAll(`g.tick text`)).toHaveLength(n_labels) + }, + ) test(`unit_on_first_tick appends unit only to the first tick`, async () => { const svg = await mount_axis({ @@ -172,6 +178,34 @@ describe(`PlotAxis`, () => { expect(no_coords.querySelector(`.axis-label`)).toBeNull() }) + // Regression guard: AxisLabel offsets its foreignObject by `-width/2` and sets its width to the + // same value, so the (CSS-centered) label content always lands on label_x — even when PlotAxis + // grows the container to plot_w (> the 200px default). A wider plot must NOT push the label + // off-center; only label_x controls horizontal placement. + test.each([ + [`narrow plot clamps container to the 200px default`, 200, 200], + [`wide plot grows container to plot_w`, 600, 540], // plot_w = 600 - pad.l(40) - pad.r(20) + ])( + `x-axis label container stays centered on label_x (%s)`, + async (_desc, plot_width, exp_w) => { + const label_x = 123 + const svg = await mount_axis({ + side: `x`, + ticks: [50], + axis: { label: `Energy` }, + label_x, + label_y: 50, + width: plot_width, + }) + const foreign_obj = query(svg, `.x-axis foreignObject`) + const foreign_obj_x = Number(foreign_obj.getAttribute(`x`)) + const foreign_obj_w = Number(foreign_obj.getAttribute(`width`)) + expect(foreign_obj_w).toBe(exp_w) + // container centered on label_x regardless of its width + expect(foreign_obj_x + foreign_obj_w / 2).toBe(label_x) + }, + ) + // Regression guard: x and x2 rotate their tick labels to opposite anchors. test.each([ [`x` as Side, `start`], diff --git a/tests/vitest/plot/PlotMarginals.test.svelte.ts b/tests/vitest/plot/PlotMarginals.test.svelte.ts new file mode 100644 index 000000000..737e0e9f9 --- /dev/null +++ b/tests/vitest/plot/PlotMarginals.test.svelte.ts @@ -0,0 +1,531 @@ +import type { DataSeries, MarginalSideInput } from '$lib/plot' +import { BarPlot, BoxPlot, Histogram, ScatterPlot } from '$lib/plot' +import { type ComponentProps, createRawSnippet, tick } from 'svelte' +import { describe, expect, test } from 'vitest' +import { mount_sized } from '../setup' + +const scatter_series: DataSeries = { + x: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + y: [5, 3, 8, 2, 7, 4, 9, 1, 6, 5], + point_style: { fill: `steelblue`, radius: 4 }, +} + +const mount_scatter = ( + props: Partial>, +): Promise => mount_sized(ScatterPlot, props, { selector: `.scatter` }) + +const mount_histogram = ( + props: Partial>, +): Promise => mount_sized(Histogram, props, { selector: `.histogram` }) + +const mount_bar = (props: Partial>): Promise => + mount_sized(BarPlot, props, { selector: `.bar-plot` }) + +const mount_box = (props: Partial>): Promise => + mount_sized(BoxPlot, props, { selector: `.box-plot` }) + +const clip_rect_height = (root: HTMLElement): number => + Number(root.querySelector(`clipPath rect`)?.getAttribute(`height`) ?? 0) + +const marker_snippet = createRawSnippet(() => ({ + render: () => ``, +})) + +describe(`PlotMarginals integration`, () => { + test(`no marginal strips render by default`, async () => { + const root = await mount_scatter({ series: [scatter_series] }) + expect(root.querySelectorAll(`.marginal`)).toHaveLength(0) + }) + + test(`marginals=true renders top + right histogram strips with bars`, async () => { + const root = await mount_scatter({ series: [scatter_series], marginals: true }) + expect(root.querySelector(`.marginal-top`)).not.toBeNull() + expect(root.querySelector(`.marginal-right`)).not.toBeNull() + expect(root.querySelector(`.marginal-bottom`)).toBeNull() + expect(root.querySelectorAll(`.marginal-top rect`).length).toBeGreaterThan(0) + expect(root.querySelectorAll(`.marginal-right rect`).length).toBeGreaterThan(0) + }) + + test(`enabling a top marginal shrinks the plot area (pad growth)`, async () => { + const with_margin = await mount_scatter({ + series: [scatter_series], + marginals: { top: { type: `histogram`, size: 90 } }, + }) + const without = await mount_scatter({ series: [scatter_series] }) + expect(clip_rect_height(with_margin)).toBeLessThan(clip_rect_height(without)) + }) + + test.each([ + [`kde`, `.marginal-top path`], + [`rug`, `.marginal-top line`], + ] as const)(`%s marginal renders %s elements`, async (type, selector) => { + const root = await mount_scatter({ + series: [scatter_series], + marginals: { top: type }, + }) + expect(root.querySelectorAll(selector).length).toBeGreaterThan(0) + // only the requested side is active + expect(root.querySelector(`.marginal-right`)).toBeNull() + }) + + test(`per-side styling props reach the rendered bars`, async () => { + const root = await mount_scatter({ + series: [scatter_series], + marginals: { top: { type: `histogram`, fill: `tomato`, fill_opacity: 0.5 } }, + }) + const bar = root.querySelector(`.marginal-top rect`) + expect(bar?.getAttribute(`fill`)).toBe(`tomato`) + expect(bar?.getAttribute(`fill-opacity`)).toBe(`0.5`) + }) + + // rug ticks have no fill, so `opacity` (not the bar/area `fill_opacity`) controls them + test(`rug marks use config.opacity, not fill_opacity`, async () => { + const root = await mount_scatter({ + series: [scatter_series], + marginals: { top: { type: `rug`, opacity: 0.3, fill_opacity: 0.9 } }, + }) + expect(root.querySelector(`.marginal-top line`)?.getAttribute(`opacity`)).toBe(`0.3`) + }) + + test(`per_series: false merges series into a single curve`, async () => { + const two = [ + scatter_series, + { x: [2, 4, 6, 8], y: [1, 2, 3, 4], point_style: { fill: `orangered` } }, + ] + const per = await mount_scatter({ series: two, marginals: { top: { type: `kde` } } }) + const merged = await mount_scatter({ + series: two, + marginals: { top: { type: `kde`, per_series: false } }, + }) + const count = (root: HTMLElement) => root.querySelectorAll(`.marginal-top path`).length + expect(count(merged)).toBeLessThan(count(per)) + }) + + test(`per_series: false merged histogram includes values from every series`, async () => { + const root = await mount_scatter({ + series: [ + { x: [0.5, 1], y: [1, 1], point_style: { fill: `steelblue` } }, + { x: [9, 9.5], y: [1, 1], point_style: { fill: `orangered` } }, + ], + x_axis: { range: [0, 10] }, + y_axis: { range: [0, 2] }, + marginals: { top: { type: `histogram`, bins: 10, per_series: false } }, + }) + const hit = root.querySelector(`.marginal-hit-top`) + if (!hit) throw new Error(`expected top marginal hit rect`) + const mid = Number(hit.getAttribute(`x`)) + Number(hit.getAttribute(`width`)) / 2 + const bar_xs = [...root.querySelectorAll(`.marginal-top rect`)].map((rect) => + Number(rect.getAttribute(`x`)), + ) + expect(bar_xs.some((x) => x < mid)).toBe(true) + expect(bar_xs.some((x) => x > mid)).toBe(true) + }) + + test(`value_range pins the marginal value axis`, async () => { + const max_height = (root: HTMLElement) => + Math.max( + 0, + ...Array.from(root.querySelectorAll(`.marginal-top rect`), (rect) => + Number(rect.getAttribute(`height`)), + ), + ) + const auto = await mount_scatter({ + series: [scatter_series], + marginals: { top: { type: `histogram`, size: 80 } }, + }) + // a value_range far above the bin counts squashes the bars + const pinned = await mount_scatter({ + series: [scatter_series], + marginals: { top: { type: `histogram`, size: 80, value_range: [0, 1000] } }, + }) + expect(max_height(pinned)).toBeLessThan(max_height(auto)) + }) + + test(`reduce wins over data and its returned curve kind is rendered`, async () => { + const root = await mount_scatter({ + series: [scatter_series], + marginals: { + top: { + type: `histogram`, // would render bars; reduce overrides with a rug curve + data: [50, 60, 70], + reduce: () => ({ kind: `rug`, positions: [2, 4, 6] }), + }, + }, + }) + expect(root.querySelectorAll(`.marginal-top line`)).toHaveLength(3) + expect(root.querySelectorAll(`.marginal-top rect`)).toHaveLength(0) + // value-axis is keyed on the actual curve kind, so a rug-returning reduce gets none + expect(root.querySelector(`.marginal-axis-top`)).toBeNull() + }) + + // a point that's finite in data space but scales to a non-finite pixel (pos 0 on a log axis) + // must be dropped so the rendered path has no NaN/Infinity coords + test(`line marginal drops points that scale to non-finite pixels`, async () => { + const root = await mount_scatter({ + series: [scatter_series], + x_axis: { scale_type: `log`, range: [1, 100] }, + marginals: { + top: { + type: `kde`, + reduce: () => ({ + kind: `line`, + points: [ + { pos: 0, value: 1 }, + { pos: 10, value: 1 }, + { pos: 50, value: 1 }, + ], + max: 1, + }), + }, + }, + }) + const paths = [...root.querySelectorAll(`.marginal-top path`)] + expect(paths.length).toBeGreaterThan(0) + for (const path of paths) { + expect(path.getAttribute(`d`) ?? ``).not.toMatch(/NaN|Infinity/) + } + }) + + test(`a custom snippet replaces the built-in rendering`, async () => { + const root = await mount_scatter({ + series: [scatter_series], + marginals: { top: { type: `histogram`, snippet: marker_snippet } }, + }) + expect(root.querySelector(`.marginal-top .custom-marker`)).not.toBeNull() + expect(root.querySelectorAll(`.marginal-top rect`)).toHaveLength(0) + }) + + test(`a marginal only summarizes series on the axis it binds to`, async () => { + const x2_only = [{ ...scatter_series, x_axis: `x2` as const }] + // default top binds x1: no x1 series -> no bars + const x1_top = await mount_scatter({ series: x2_only, marginals: { top: `histogram` } }) + expect(x1_top.querySelectorAll(`.marginal-top rect`)).toHaveLength(0) + // binding the top marginal to x2 picks up the x2 series + const x2_top = await mount_scatter({ + series: x2_only, + marginals: { top: { type: `histogram`, axis: `x2` } }, + }) + expect(x2_top.querySelectorAll(`.marginal-top rect`).length).toBeGreaterThan(0) + }) + + // the marginal binds to the value axis, so its default side follows orientation: BarPlot's + // Pareto sits top (vertical) / right (horizontal); BoxPlot transposes the other way + test.each([ + { + name: `BarPlot`, + sides: [`top`, `right`] as const, // [vertical default, horizontal] + mount: (orientation?: `horizontal`) => + mount_bar({ + series: [{ x: [`A`, `B`, `C`, `D`], y: [10, 5, 3, 2], color: `slateblue` }], + orientation, + marginals: true, + }), + }, + { + name: `BoxPlot`, + sides: [`right`, `top`] as const, + mount: (orientation?: `horizontal`) => + mount_box({ + series: [ + { y: [1, 2, 2, 3, 3, 3, 4, 4, 5], label: `A` }, + { y: [2, 3, 3, 4, 4, 5, 5, 6], label: `B` }, + ], + orientation, + marginals: true, + }), + }, + ])(`$name marginal side follows orientation`, async ({ sides: [vside, hside], mount }) => { + const vertical = await mount() + expect(vertical.querySelector(`.marginal-${vside}`)).not.toBeNull() + expect(vertical.querySelector(`.marginal-${hside}`)).toBeNull() + const horizontal = await mount(`horizontal`) + expect(horizontal.querySelector(`.marginal-${hside}`)).not.toBeNull() + expect(horizontal.querySelector(`.marginal-${vside}`)).toBeNull() + }) + + // magnitude weights keep the CDF monotonic even when bars are negative (signed weights zigzag) + test(`BarPlot CDF marginal stays monotonic with negative bars`, async () => { + const root = await mount_bar({ + series: [{ x: [`A`, `B`, `C`, `D`], y: [10, -8, 6, -4], color: `slateblue` }], + marginals: { top: { type: `cdf`, curve: `linear` } }, + }) + const line_path = root.querySelector(`.marginal-top path[fill="none"]`) + if (!line_path) throw new Error(`expected a CDF line path`) + const nums = (line_path.getAttribute(`d`) ?? ``).match(/-?\d+\.?\d*/g)?.map(Number) ?? [] + const ys = nums.filter((_, idx) => idx % 2 === 1) + expect(ys.length).toBeGreaterThan(2) + const ascending = [...ys].sort((a, b) => a - b) + const is_monotonic = + ys.every((val, idx) => val === ascending[idx]) || + ys.every((val, idx) => val === ascending[ascending.length - 1 - idx]) + expect(is_monotonic).toBe(true) + }) +}) + +describe(`marginal hover tooltips`, () => { + // happy-dom has no layout: getBoundingClientRect() is all zeros, so clientX/clientY map straight + // to wrapper px. Pick a coordinate just inside the plot-facing baseline where filled marginals + // are rendered, not merely inside the transparent hit-rect. + const hover_strip = async (root: HTMLElement): Promise => { + const hit = root.querySelector(`.marginal-hit`) + if (!hit) throw new Error(`expected a .marginal-hit rect`) + const x = Number(hit.getAttribute(`x`)) + const y = Number(hit.getAttribute(`y`)) + const width = Number(hit.getAttribute(`width`)) + const height = Number(hit.getAttribute(`height`)) + const side = /marginal-hit-(?top|right|bottom|left)/.exec( + hit.getAttribute(`class`) ?? ``, + )?.groups?.side + const bar = side ? root.querySelector(`.marginal-${side} rect`) : null + const center_x = x + width / 2 + const center_y = y + height / 2 + let clientX = side === `left` ? x + width - 1 : side === `right` ? x + 1 : center_x + let clientY = side === `top` ? y + height - 1 : side === `bottom` ? y + 1 : center_y + if (bar) { + clientX = Number(bar.getAttribute(`x`)) + Number(bar.getAttribute(`width`)) / 2 + clientY = Number(bar.getAttribute(`y`)) + Number(bar.getAttribute(`height`)) / 2 + } + hit.dispatchEvent(new MouseEvent(`pointermove`, { clientX, clientY, bubbles: true })) + await tick() + return root.querySelector(`.plot-tooltip`) + } + + test(`pointermove only shows a tooltip over a filled strip area`, async () => { + const root = await mount_scatter({ + series: [scatter_series], + marginals: { top: { type: `histogram`, value_range: [0, 1000] } }, + }) + expect(root.querySelector(`.plot-tooltip`)).toBeNull() // none before hover + const hit = root.querySelector(`.marginal-hit`) + if (!hit) throw new Error(`expected a .marginal-hit rect`) + hit.dispatchEvent( + new MouseEvent(`pointermove`, { + clientX: Number(hit.getAttribute(`x`)) + Number(hit.getAttribute(`width`)) / 2, + clientY: Number(hit.getAttribute(`y`)) + 1, + bubbles: true, + }), + ) + await tick() + expect(root.querySelector(`.plot-tooltip`)).toBeNull() + + const tooltip = await hover_strip(root) + expect(tooltip).not.toBeNull() + expect(tooltip?.textContent).toContain(`range`) + // tooltip is portaled out of the (an svg can't host an HTML tooltip) into the wrapper + expect(tooltip?.closest(`svg`)).toBeNull() + expect(tooltip?.closest(`.scatter`)).not.toBeNull() + + root + .querySelector(`.marginal-hit`) + ?.dispatchEvent(new MouseEvent(`pointerleave`, { bubbles: true })) + await tick() + expect(root.querySelector(`.plot-tooltip`)).toBeNull() + }) + + // hover:false and custom snippets both opt out of the hit-rect while still drawing the strip + test.each([ + [`hover: false`, { type: `histogram`, hover: false }], + [`a custom snippet`, { type: `histogram`, snippet: marker_snippet }], + ] as const)(`%s renders the strip but no hit-rect`, async (_desc, top) => { + const root = await mount_scatter({ series: [scatter_series], marginals: { top } }) + expect(root.querySelector(`.marginal-top`)).not.toBeNull() + expect(root.querySelector(`.marginal-hit`)).toBeNull() + }) + + // AXIS_DEFAULTS.format is `` (empty), so the pos fallback must use || (not ??) to avoid raw floats + test(`tooltip position uses the compact default format when the axis format is empty`, async () => { + const root = await mount_scatter({ series: [scatter_series], marginals: { top: `kde` } }) + const text = (await hover_strip(root))?.textContent ?? `` + expect(text).toContain(`pos`) + expect(text).not.toMatch(/\d\.\d{6,}/) // compact `.3~g`, never a raw 16-digit float + }) + + test(`BarPlot categorical marginal tooltip shows the category label, not the index`, async () => { + const root = await mount_bar({ + series: [{ x: [`A`, `B`, `C`, `D`], y: [10, 5, 3, 2], color: `slateblue` }], + marginals: true, // default top CDF over the categorical x-axis + }) + const text = (await hover_strip(root))?.textContent ?? `` + expect(text).toMatch(/pos: [ABCD]/) // a category letter, never "pos: 0" + expect(text).not.toMatch(/pos: \d/) + }) + + // the position row is labelled with the host axis title of the axis the strip shares (top/bottom + // share x, left/right share y), falling back to `range` (bars) / `pos` (else) without a title + test.each([ + [ + `top kde -> x-axis title`, + { marginals: { top: `kde` }, x_axis: { label: `Error` } }, + `Error`, + ], + [ + `right kde -> y-axis title`, + { marginals: { right: `kde` }, y_axis: { label: `Energy` } }, + `Energy`, + ], + [ + `bars without a title -> "range"`, + { marginals: { top: { type: `histogram` } } }, + `range`, + ], + [ + `empty title -> "pos" (|| not ??)`, + { marginals: { top: `kde` }, x_axis: { label: `` } }, + `pos`, + ], + ] as [string, Partial>, string][])( + `position row label: %s`, + async (_desc, props, expected) => { + const root = await mount_scatter({ series: [scatter_series], ...props }) + expect((await hover_strip(root))?.textContent ?? ``).toContain(`${expected}: `) + }, + ) + + // axis titles routinely carry markup (e.g. Ehull); it must render, not show raw tags + test(`an axis title with HTML markup renders as markup, not literal tags`, async () => { + const root = await mount_scatter({ + series: [scatter_series], + x_axis: { label: `Ehull` }, + marginals: { top: `kde` }, + }) + await hover_strip(root) + const tip = root.querySelector(`.plot-tooltip`) + expect(tip?.querySelector(`sub`)?.textContent).toBe(`hull`) // rendered element + expect(tip?.textContent).not.toContain(``) // no raw tags + }) + + // counterpart to the above: the value/category portion (head_value) is NOT @html, so markup in a + // category renders literally (both categories carry ` { + const root = await mount_bar({ + series: [{ x: [`a element + expect(tip?.textContent).toMatch(/[az] { + const tooltip = createRawSnippet(() => ({ + render: () => `custom marginal tip`, + })) + const root = await mount_scatter({ + series: [scatter_series], + marginals: { top: { type: `kde`, tooltip } }, + }) + await hover_strip(root) + expect(root.querySelector(`.plot-tooltip .custom-tip`)?.textContent).toBe( + `custom marginal tip`, + ) + }) +}) + +describe(`marginal value-axis`, () => { + const samples = [1, 2, 2, 3, 3, 3, 4, 4, 5] + const hist_series = [{ x: samples.map((_, idx) => idx), y: samples, label: `vals` }] + + const tick_labels = (axis: Element | null): string[] => + [...(axis?.querySelectorAll(`text:not(.marginal-axis-title)`) ?? [])].map( + (node) => node.textContent ?? ``, + ) + + // same setup, so content (title/ticks/line) and geometry (spine/label placement/title rotation) + // of the default top CDF value-axis are asserted together + test(`Histogram default top CDF value-axis: title, percent ticks, and x-strip geometry`, async () => { + const root = await mount_histogram({ series: hist_series, marginals: true }) + expect(root.querySelectorAll(`.marginal-top path`).length).toBeGreaterThan(0) // cdf line + const axis = root.querySelector(`.marginal-axis-top`) + if (!axis) throw new Error(`expected a top value-axis`) + expect(axis.querySelector(`.marginal-axis-title`)?.textContent).toBe(`CDF`) + // CDF domain is [0,1] -> nice ticks [0,0.5,1] as percentages; the baseline 0% tick is dropped + // (it would overlap the host plot's top y-tick), leaving 50% and 100% + expect(tick_labels(axis)).toEqual(expect.arrayContaining([`50%`, `100%`])) + expect(tick_labels(axis)).not.toContain(`0%`) + // geometry: vertical spine (x1 === x2), tick labels OUTSIDE it (to its left, like a y-axis), + // and a title rotated -90 (reads bottom-to-top) + const spine_x = Number(axis.querySelector(`line`)?.getAttribute(`x1`)) + expect(axis.querySelector(`line`)?.getAttribute(`x2`)).toBe(String(spine_x)) + const tick_el = axis.querySelector(`text:not(.marginal-axis-title)`) + expect(Number(tick_el?.getAttribute(`x`))).toBeLessThan(spine_x) + expect(axis.querySelector(`.marginal-axis-title`)?.getAttribute(`transform`)).toContain( + `rotate(-90`, + ) + }) + + test(`histogram normalize drives the title and percent ticks`, async () => { + const root = await mount_histogram({ + series: hist_series, + marginals: { top: { type: `histogram`, normalize: `probability` } }, + }) + const axis = root.querySelector(`.marginal-axis-top`) + expect(axis?.querySelector(`.marginal-axis-title`)?.textContent).toBe(`probability`) + expect(tick_labels(axis).some((text) => text.endsWith(`%`))).toBe(true) + }) + + test(`value_range pins the value-axis tick labels`, async () => { + const root = await mount_histogram({ + series: hist_series, + marginals: { top: { type: `histogram`, value_range: [0, 1000] } }, + }) + // count format is .3~s, so d3 ticks [0,500,1000] -> "0","500","1k"; the baseline 0 tick is + // dropped (avoids overlapping the host plot's top y-tick), leaving "500" and "1k" + expect(tick_labels(root.querySelector(`.marginal-axis-top`))).toEqual( + expect.arrayContaining([`500`, `1k`]), + ) + }) + + test(`y-strip (right kde) renders a horizontal 'density' value-axis`, async () => { + const root = await mount_scatter({ + series: [scatter_series], + marginals: { right: { type: `kde` } }, + }) + const axis = root.querySelector(`.marginal-axis-right`) + expect(axis).not.toBeNull() + expect(axis?.querySelector(`.marginal-axis-title`)?.textContent).toBe(`density`) + expect(tick_labels(axis).length).toBeGreaterThan(0) + // y-strip spine is horizontal (y1 === y2) + const spine = axis?.querySelector(`line`) + expect(spine?.getAttribute(`y2`)).toBe(spine?.getAttribute(`y1`)) + }) + + test(`label overrides the auto value-axis title`, async () => { + const root = await mount_histogram({ + series: hist_series, + marginals: { top: { type: `cdf`, label: `Cumulative` } }, + }) + expect(root.querySelector(`.marginal-axis-top .marginal-axis-title`)?.textContent).toBe( + `Cumulative`, + ) + }) + + // rug has no value, and value_axis:false opts out: both render the strip but no value-axis + test.each([ + [`value_axis: false`, { type: `cdf`, value_axis: false }], + [`rug type`, { type: `rug` }], + ] as const)(`%s renders the strip but no value-axis`, async (_desc, top) => { + const root = await mount_histogram({ series: hist_series, marginals: { top } }) + expect(root.querySelector(`.marginal-top`)).not.toBeNull() + expect(root.querySelector(`.marginal-axis-top`)).toBeNull() + }) + + // empty data => degenerate [0,0] domain, so no value-axis is drawn... unless a value_range pins + // the scale, in which case the axis renders even with no data + test.each([ + [`empty data renders no value-axis`, { type: `histogram` }, false], + [ + `value_range renders it even with empty data`, + { + type: `histogram`, + value_range: [0, 100], + }, + true, + ], + ] as [string, MarginalSideInput, boolean][])(`%s`, async (_desc, top, present) => { + const root = await mount_histogram({ + series: [{ x: [], y: [], label: `empty` }], + marginals: { top }, + }) + expect(root.querySelector(`.marginal-axis-top`) !== null).toBe(present) + }) +}) diff --git a/tests/vitest/plot/ScatterPlot.test.svelte.ts b/tests/vitest/plot/ScatterPlot.test.svelte.ts index d773a0ff7..68cc61691 100644 --- a/tests/vitest/plot/ScatterPlot.test.svelte.ts +++ b/tests/vitest/plot/ScatterPlot.test.svelte.ts @@ -540,26 +540,26 @@ describe(`ScatterPlot`, () => { }) test(`keeps unique fill ID hover stable when source index changes`, async () => { - let fill_regions = $state([ - { id: `target`, lower: 0, upper: 0.2, fill: `steelblue` }, - ]) + const state = $state({ + fill_regions: [{ id: `target`, lower: 0, upper: 0.2, fill: `steelblue` }], + }) mount(ScatterPlot, { target: document.body, - props: { - series: [{ x: [0, 1], y: [0, 1] }], - x_axis: { range: [0, 1] as Vec2 }, - y_axis: { range: [0, 1] as Vec2 }, - get fill_regions() { - return fill_regions + props: bind_props( + { + series: [{ x: [0, 1], y: [0, 1] }], + x_axis: { range: [0, 1] as Vec2 }, + y_axis: { range: [0, 1] as Vec2 }, + legend: null, + style: `width: 400px; height: 300px;`, }, - legend: null, - style: `width: 400px; height: 300px;`, - }, + state, + ), }) await resize_element(doc_query(`.scatter`), 400, 300) await hover(svg_query(`[aria-label="Fill region 0"]`)) - fill_regions = [ + state.fill_regions = [ { id: `inserted`, lower: 0.3, upper: 0.4, fill: `transparent` }, { id: `target`, lower: 0, upper: 0.2, fill: `steelblue` }, ] @@ -593,21 +593,23 @@ describe(`ScatterPlot`, () => { }) test(`hidden fill keeps its legend item so it can be toggled back on`, async () => { - let fill_regions = $state([ - { id: `band`, label: `Band`, lower: 0, upper: 0.5, fill: `steelblue` }, - ]) + const state = $state({ + fill_regions: [ + { id: `band`, label: `Band`, lower: 0, upper: 0.5, fill: `steelblue` }, + ] as FillRegion[], + }) mount(ScatterPlot, { target: document.body, - props: { - series: [{ x: [0, 1], y: [0, 1] }], - x_axis: { range: [0, 1] as Vec2 }, - y_axis: { range: [0, 1] as Vec2 }, - get fill_regions() { - return fill_regions + props: bind_props( + { + series: [{ x: [0, 1], y: [0, 1] }], + x_axis: { range: [0, 1] as Vec2 }, + y_axis: { range: [0, 1] as Vec2 }, + legend: {}, + style: `width: 400px; height: 300px;`, }, - legend: {}, - style: `width: 400px; height: 300px;`, - }, + state, + ), }) await resize_element(doc_query(`.scatter`), 400, 300) await tick() @@ -622,7 +624,7 @@ describe(`ScatterPlot`, () => { expect(fill_item()).toBeDefined() // hide it (what clicking the legend fill item does via the fill_regions binding) - fill_regions = [{ ...fill_regions[0], visible: false }] + state.fill_regions = [{ ...state.fill_regions[0], visible: false }] flushSync() await tick() diff --git a/tests/vitest/plot/axis-utils.test.ts b/tests/vitest/plot/axis-utils.test.ts index dadee52a1..b057f7ba3 100644 --- a/tests/vitest/plot/axis-utils.test.ts +++ b/tests/vitest/plot/axis-utils.test.ts @@ -1,5 +1,5 @@ -import type { AxisConfig, DataSeries } from '$lib/plot' -import { create_axis_change_handler, merge_series_state } from '$lib/plot/core/axis-utils' +import type { AxisConfig, AxisLoadError, DataLoaderFn, DataSeries } from '$lib/plot' +import { create_axis_loader, merge_series_state } from '$lib/plot/core/axis-utils' import { describe, expect, test, vi } from 'vitest' describe(`merge_series_state`, () => { @@ -50,109 +50,115 @@ describe(`merge_series_state`, () => { expect(merged[1].visible).toBe(false) }) - test(`keeps new series defaults when id has no old match but value is provided`, () => { - const old_series: DataSeries[] = [{ id: `a`, x: [1], y: [1], visible: false }] - const new_series: DataSeries[] = [{ id: `c`, x: [30], y: [30], visible: true }] - const merged = merge_series_state(old_series, new_series) - expect(merged[0].visible).toBe(true) - }) - - test(`does not fall back to position when id has no old match`, () => { - const old_series: DataSeries[] = [{ id: `a`, x: [1], y: [1], visible: false }] - const new_series: DataSeries[] = [{ id: `c`, x: [30], y: [30] }] - const merged = merge_series_state(old_series, new_series) - expect(merged[0].visible).toBeUndefined() + // An unmatched id keeps the new series' own value (true) and never inherits by position (undefined) + test.each([ + { name: `keeps provided visibility`, visible: true, expected: true }, + { name: `does not inherit by position`, visible: undefined, expected: undefined }, + ])(`for an unmatched id, $name`, ({ visible, expected }) => { + const merged = merge_series_state( + [{ id: `a`, x: [1], y: [1], visible: false }], + [{ id: `c`, x: [30], y: [30], visible }], + ) + expect(merged[0].visible).toBe(expected) }) }) -describe(`create_axis_change_handler`, () => { - // Create a mock state object for testing. +describe(`create_axis_loader`, () => { + // Create a mock state object for testing. Each axis gets its OWN accessor so tests can + // verify the change is routed to the correct axis (not hardcoded to `x`). function create_mock_state(initial_key: string = `energy`) { - let axis_config: AxisConfig = { selected_key: initial_key } let series: DataSeries[] = [] let loading: `x` | `x2` | `y` | `y2` | null = null + const make_axis = (key: string) => { + let axis_config: AxisConfig = { selected_key: key } + return { + get: vi.fn(() => axis_config), + set: vi.fn((config: AxisConfig) => { + axis_config = config + }), + } + } return { - get_axis: vi.fn((_axis: `x` | `x2` | `y` | `y2`) => axis_config), - set_axis: vi.fn((_axis: `x` | `x2` | `y` | `y2`, config: AxisConfig) => { - axis_config = config - }), - get_series: vi.fn(() => series), - set_series: vi.fn((new_series: DataSeries[]) => { - series = new_series - }), - get_loading: vi.fn(() => loading), - set_loading: vi.fn((axis: `x` | `x2` | `y` | `y2` | null) => { - loading = axis - }), + axes: { + x: make_axis(initial_key), + x2: make_axis(initial_key), + y: make_axis(initial_key), + y2: make_axis(initial_key), + }, + series: { + get: vi.fn(() => series), + set: vi.fn((new_series: DataSeries[]) => { + series = new_series + }), + }, + loading: { + get: vi.fn(() => loading), + set: vi.fn((next: `x` | `x2` | `y` | `y2` | null) => { + loading = next + }), + }, } } - test(`does not call data_loader when key unchanged and series loaded (no-op guard)`, async () => { + type MockState = ReturnType + type LoaderProps = { + data_loader?: DataLoaderFn, DataSeries> + on_axis_change?: (axis: `x` | `x2` | `y` | `y2`, key: string, series: DataSeries[]) => void + on_error?: (error: AxisLoadError) => void + } + const make_handler = (state: MockState, props: LoaderProps) => + create_axis_loader(state, () => props).handle_axis_change + + // Each guard short-circuits before touching state or calling the loader + test.each([ + { + name: `key unchanged and series already loaded`, + arrange: (state: MockState) => + (state.series.get = vi.fn(() => [{ x: [1], y: [1] }] as DataSeries[])), + with_loader: true, + key: `energy`, + }, + { name: `data_loader is undefined`, arrange: () => {}, with_loader: false, key: `volume` }, + { + name: `a load is already in progress`, + arrange: (state: MockState) => (state.loading.get = vi.fn(() => `y`)), + with_loader: true, + key: `volume`, + }, + ])(`is a no-op when $name`, async ({ arrange, with_loader, key }) => { const state = create_mock_state(`energy`) - // Initialize with existing series so no-op guard is active - state.get_series = vi.fn( - () => - [ - { - x: [1], - y: [1], - }, - ] as DataSeries[], - ) - const data_loader = vi.fn().mockResolvedValue({ series: [] }) + arrange(state) + const data_loader = with_loader ? vi.fn().mockResolvedValue({ series: [] }) : undefined const on_axis_change = vi.fn() - const handler = create_axis_change_handler(state, data_loader, on_axis_change) - - // Call with the same key as currently selected - await handler(`x`, `energy`) + await make_handler(state, { data_loader, on_axis_change })(`x`, key) - // data_loader should NOT be called because key didn't change and series exists - expect(data_loader).not.toHaveBeenCalled() + if (data_loader) expect(data_loader).not.toHaveBeenCalled() expect(on_axis_change).not.toHaveBeenCalled() - expect(state.set_loading).not.toHaveBeenCalled() + expect(state.axes.x.set).not.toHaveBeenCalled() + expect(state.loading.set).not.toHaveBeenCalled() + expect(state.series.set).not.toHaveBeenCalled() }) - test(`calls data_loader when key changes`, async () => { + // a load fires both when the key changes and when it's unchanged but series are empty (initial + // lazy load); both commit the series, fire on_axis_change, and clear the loading flag + test.each([ + { name: `key changes`, key: `volume` }, + { name: `key unchanged but series are empty (initial lazy load)`, key: `energy` }, + ])(`calls data_loader when $name`, async ({ key }) => { const state = create_mock_state(`energy`) - const new_series: DataSeries[] = [{ x: [1], y: [2], label: `test` }] + const new_series: DataSeries[] = [{ x: [1], y: [2], label: `loaded` }] const data_loader = vi.fn().mockResolvedValue({ series: new_series }) const on_axis_change = vi.fn() - const handler = create_axis_change_handler(state, data_loader, on_axis_change) - - await handler(`x`, `volume`) - - expect(data_loader).toHaveBeenCalledWith(`x`, `volume`, []) - expect(on_axis_change).toHaveBeenCalledWith(`x`, `volume`, new_series) - expect(state.set_loading).toHaveBeenCalledWith(`x`) - expect(state.set_loading).toHaveBeenLastCalledWith(null) - }) - - test(`returns early when data_loader is undefined`, async () => { - const state = create_mock_state(`energy`) - const on_axis_change = vi.fn() - - const handler = create_axis_change_handler(state, undefined, on_axis_change) - - await handler(`x`, `volume`) + await make_handler(state, { data_loader, on_axis_change })(`x`, key) - expect(state.set_axis).not.toHaveBeenCalled() - expect(on_axis_change).not.toHaveBeenCalled() - }) - - test(`returns early when loading in progress`, async () => { - const state = create_mock_state(`energy`) - // Simulate loading in progress - state.get_loading = vi.fn(() => `y`) - const data_loader = vi.fn().mockResolvedValue({ series: [] }) - - const handler = create_axis_change_handler(state, data_loader) - - await handler(`x`, `volume`) - - expect(data_loader).not.toHaveBeenCalled() + expect(data_loader).toHaveBeenCalledWith(`x`, key, []) + expect(on_axis_change).toHaveBeenCalledWith(`x`, key, new_series) + expect(state.series.set).toHaveBeenCalledWith(new_series) + expect(state.loading.set).toHaveBeenCalledWith(`x`) + expect(state.loading.set).toHaveBeenLastCalledWith(null) }) test(`reverts selection and calls on_error on data_loader failure`, async () => { @@ -161,56 +167,141 @@ describe(`create_axis_change_handler`, () => { const data_loader = vi.fn().mockRejectedValue(new Error(`Network error`)) const on_error = vi.fn() - const handler = create_axis_change_handler(state, data_loader, undefined, on_error) - - await handler(`x`, `volume`) + await make_handler(state, { data_loader, on_error })(`x`, `volume`) expect(on_error).toHaveBeenCalledWith({ axis: `x`, key: `volume`, message: `Network error`, }) - // Check that set_axis was called to revert (second call after initial update) - expect(state.set_axis).toHaveBeenCalledTimes(2) - expect(state.set_loading).toHaveBeenLastCalledWith(null) + // set called twice: initial selected_key update, then revert + expect(state.axes.x.set).toHaveBeenCalledTimes(2) + expect(state.loading.set).toHaveBeenLastCalledWith(null) error_spy.mockRestore() }) - test(`updates axis label and unit when provided in result`, async () => { + // a loader result's axis_label/axis_unit overwrite the axis fields (alongside the selected_key + // update); empty strings (not just truthy values) overwrite, so a loader can clear them + test.each([ + { + name: `updates label/unit when provided`, + seed: { selected_key: `energy` }, + result: { axis_label: `Volume`, axis_unit: `ų` }, + expected: { selected_key: `volume`, label: `Volume`, unit: `ų` }, + }, + { + name: `clears label/unit when the loader returns empty strings`, + seed: { selected_key: `energy`, label: `Old`, unit: `eV` }, + result: { axis_label: ``, axis_unit: `` }, + expected: { selected_key: `volume`, label: ``, unit: `` }, + }, + ])(`$name`, async ({ seed, result, expected }) => { const state = create_mock_state(`energy`) - const data_loader = vi.fn().mockResolvedValue({ - series: [], - axis_label: `Volume`, - axis_unit: `ų`, - }) + state.axes.x.set(seed) // seed via stateful mock + const data_loader = vi.fn().mockResolvedValue({ series: [], ...result }) - const handler = create_axis_change_handler(state, data_loader) + await make_handler(state, { data_loader })(`x`, `volume`) - await handler(`x`, `volume`) + expect(state.axes.x.get()).toEqual(expected) + }) - // set_axis called twice: selected_key update + label/unit update - expect(state.set_axis).toHaveBeenCalledTimes(2) - expect(state.set_axis).toHaveBeenCalledWith( - `x`, - expect.objectContaining({ label: `Volume`, unit: `ų` }), - ) + // verifies the loader READS the addressed axis (not a hardcoded `x`): y is already on `volume` + // with series loaded, so the no-op guard fires only if prev_key is read from `y` + test(`reads the addressed axis when applying the no-op guard`, async () => { + const state = create_mock_state(`energy`) + state.axes.y.set({ selected_key: `volume` }) + state.series.set([{ x: [1], y: [1] }] as DataSeries[]) + const data_loader = vi.fn().mockResolvedValue({ series: [] }) + + await make_handler(state, { data_loader })(`y`, `volume`) + + // reading `y` sees key unchanged + series present -> no-op; a regression reading `x` + // (key `energy`) would mismatch `volume` and wrongly trigger a load + expect(data_loader).not.toHaveBeenCalled() }) - test(`calls data_loader when key unchanged but series empty (initial lazy load)`, async () => { + test(`a throwing on_axis_change does not trigger the loader rollback`, async () => { const state = create_mock_state(`energy`) - // Series is empty (default), simulating initial state before any data loaded - const new_series: DataSeries[] = [{ x: [1], y: [2], label: `loaded` }] + const new_series: DataSeries[] = [{ x: [1], y: [2] }] const data_loader = vi.fn().mockResolvedValue({ series: new_series }) - const on_axis_change = vi.fn() + const on_error = vi.fn() + const on_axis_change = vi.fn(() => { + throw new Error(`consumer callback blew up`) + }) + + // the load succeeded, so the callback throw must propagate -- not be swallowed as a load failure + await expect( + make_handler(state, { data_loader, on_axis_change, on_error })(`x`, `volume`), + ).rejects.toThrow(`consumer callback blew up`) + + expect(on_error).not.toHaveBeenCalled() // not misclassified as a loader failure + expect(state.series.set).toHaveBeenCalledWith(new_series) // series stays committed + // only the selected_key update ran; no revert set call + expect(state.axes.x.set).toHaveBeenCalledTimes(1) + expect(state.loading.set).toHaveBeenLastCalledWith(null) // loading still cleared + }) + + // routes the change to the addressed axis only (guards against a hardcoded-axis regression) + test.each([`x`, `x2`, `y`, `y2`] as const)( + `routes a load to the %s axis only`, + async (axis) => { + const state = create_mock_state(`energy`) + const new_series: DataSeries[] = [{ x: [1], y: [2] }] + const data_loader = vi.fn().mockResolvedValue({ series: new_series }) + const on_axis_change = vi.fn() + + await make_handler(state, { data_loader, on_axis_change })(axis, `volume`) + + expect(data_loader).toHaveBeenCalledWith(axis, `volume`, []) + expect(on_axis_change).toHaveBeenCalledWith(axis, `volume`, new_series) + expect(state.axes[axis].set).toHaveBeenCalledWith({ selected_key: `volume` }) + for (const other of [`x`, `x2`, `y`, `y2`] as const) { + if (other !== axis) expect(state.axes[other].set).not.toHaveBeenCalled() + } + }, + ) + + // try_auto_load picks the first axis (x before y) whose options are populated, once + test.each([ + { name: `prefers x when both have options`, x_opts: true, y_opts: true, loaded: `x` }, + { + name: `falls back to y when only y has options`, + x_opts: false, + y_opts: true, + loaded: `y`, + }, + { name: `no-op when no axis has options`, x_opts: false, y_opts: false, loaded: null }, + ])(`try_auto_load $name`, ({ x_opts, y_opts, loaded }) => { + const state = create_mock_state(`energy`) + const with_opts = (key: string) => ({ selected_key: key, options: [{ key, label: key }] }) + state.axes.x.get = vi.fn(() => (x_opts ? with_opts(`energy`) : { selected_key: `energy` })) + state.axes.y.get = vi.fn(() => (y_opts ? with_opts(`volume`) : { selected_key: `volume` })) + const data_loader = vi.fn().mockResolvedValue({ series: [{ x: [1], y: [1] }] }) - const handler = create_axis_change_handler(state, data_loader, on_axis_change) + const { try_auto_load } = create_axis_loader(state, () => ({ data_loader })) + try_auto_load() - // Call with the SAME key as selected_key - should still load since series empty - await handler(`x`, `energy`) + // data_loader is invoked synchronously up to its first await + if (loaded) expect(data_loader).toHaveBeenCalledWith(loaded, expect.any(String), []) + else expect(data_loader).not.toHaveBeenCalled() + }) - expect(data_loader).toHaveBeenCalledWith(`x`, `energy`, []) - expect(on_axis_change).toHaveBeenCalledWith(`x`, `energy`, new_series) - expect(state.set_series).toHaveBeenCalledWith(new_series) + test(`try_auto_load only attempts once even after failure`, async () => { + const state = create_mock_state(`energy`) + state.axes.x.get = vi.fn(() => ({ + selected_key: `energy`, + options: [{ key: `energy`, label: `E` }], + })) + const error_spy = vi.spyOn(console, `error`).mockImplementation(() => {}) + const data_loader = vi.fn().mockRejectedValue(new Error(`boom`)) + + const { try_auto_load } = create_axis_loader(state, () => ({ data_loader })) + try_auto_load() + await Promise.resolve() + try_auto_load() + + expect(data_loader).toHaveBeenCalledTimes(1) + error_spy.mockRestore() }) }) diff --git a/tests/vitest/plot/data-cleaning.test.ts b/tests/vitest/plot/data-cleaning.test.ts index d626ce8a6..8a4aa601e 100644 --- a/tests/vitest/plot/data-cleaning.test.ts +++ b/tests/vitest/plot/data-cleaning.test.ts @@ -55,6 +55,10 @@ function generate_unstable_data( return { x, y } } +// Population variance about a known mean — used to assert smoothing reduces spread +const variance = (values: readonly number[], mean: number): number => + values.reduce((sum, val) => sum + (val - mean) ** 2, 0) / values.length + describe(`compute_local_variance`, () => { it.each([ { input: [], window: 5, expected: [], desc: `empty array` }, @@ -374,7 +378,7 @@ describe(`apply_bounds`, () => { { mode: `filter` as const, expected_y: [-5, 0, 5, 10, 15], filtered: [0, 4] }, ])(`$mode mode works correctly`, ({ mode, expected_y, filtered }) => { const result = apply_bounds(x, y, { min: 0, max: 10, mode }) - if (mode === `clamp`) expect(result.y).toEqual(expected_y) + expect(result.y).toEqual(expected_y) // filter leaves y untouched; clamp pins to [0, 10] expect(result.filtered_indices).toEqual(filtered) expect(result.violations).toBe(2) }) @@ -441,11 +445,7 @@ describe(`smooth_savitzky_golay`, () => { const oscillating = [0, 2, 0, 2, 0, 2, 0, 2, 0] const smoothed_osc = smooth_savitzky_golay(oscillating, 5, 2) - const orig_var = - oscillating.reduce((sum, val) => sum + (val - 1) ** 2, 0) / oscillating.length - const smooth_var = - smoothed_osc.reduce((sum, val) => sum + (val - 1) ** 2, 0) / smoothed_osc.length - expect(smooth_var).toBeLessThan(orig_var) + expect(variance(smoothed_osc, 1)).toBeLessThan(variance(oscillating, 1)) }) it(`handles different polynomial orders and NaN values`, () => { @@ -454,6 +454,23 @@ describe(`smooth_savitzky_golay`, () => { expect(smooth_savitzky_golay(quadratic, 5, 2)).toHaveLength(9) expect(smooth_savitzky_golay([1, 2, NaN, 4, 5, 6, 7], 5, 2)).toHaveLength(7) }) + + // Regression: the Math.max(window, order + 2) and Math.min(window, length) clamps could + // leave actual_window even, producing an asymmetric kernel + a normalization mismatch + // (coeffs_sum summed more taps than the loop). SG must still reproduce polynomials of + // degree <= order at interior points. Pre-fix, window=3/order=2 gave interior errors ~11. + it.each([ + { length: 21, window: 3, order: 2, desc: `max(window, order+2) -> even 4` }, + { length: 21, window: 1, order: 2, desc: `tiny window -> even floor` }, + { length: 6, window: 9, order: 2, desc: `min(window, even length) -> even 6` }, + { length: 21, window: 5, order: 2, desc: `already odd (control)` }, + ])(`reproduces quadratics at interior points: $desc`, ({ length, window, order }) => { + const quad = Array.from({ length }, (_, idx) => 2 * idx * idx - 3 * idx + 5) + const out = smooth_savitzky_golay(quad, window, order) + expect(out).toHaveLength(length) + // half = 2 stays clear of edge effects for every effective window above (3 or 5) + for (let idx = 2; idx < length - 2; idx++) expect(out[idx]).toBeCloseTo(quad[idx], 6) + }) }) describe(`sync_metadata`, () => { @@ -506,36 +523,25 @@ describe(`clean_series`, () => { }) it(`applies physical bounds with different modes`, () => { + // in_place: false makes clean_series copy x/y internally, so the same series is safe to reuse const series: DataSeries = { x: [0, 1, 2, 3, 4], y: [-10, 5, 10, 15, 100] } - const clamp = clean_series( - { ...series, x: [...series.x], y: [...series.y] }, - { - bounds: { min: 0, mode: `clamp` }, - in_place: false, - }, - ) + const clamp = clean_series(series, { bounds: { min: 0, mode: `clamp` }, in_place: false }) expect(clamp.series.y[0]).toBe(0) - const filter = clean_series( - { ...series, x: [...series.x], y: [...series.y] }, - { - bounds: { min: 0, max: 20, mode: `filter` }, - in_place: false, - }, - ) + const filter = clean_series(series, { + bounds: { min: 0, max: 20, mode: `filter` }, + in_place: false, + }) expect(filter.series.x).toEqual([1, 2, 3]) expect(filter.quality.points_removed).toBe(2) - const nullMode = clean_series( - { ...series, x: [...series.x], y: [...series.y] }, - { - bounds: { min: 0, max: 20, mode: `null` }, - in_place: false, - }, - ) - expect(Number.isNaN(nullMode.series.y[0])).toBe(true) - expect(Number.isNaN(nullMode.series.y[4])).toBe(true) + const null_mode = clean_series(series, { + bounds: { min: 0, max: 20, mode: `null` }, + in_place: false, + }) + expect(Number.isNaN(null_mode.series.y[0])).toBe(true) + expect(Number.isNaN(null_mode.series.y[4])).toBe(true) }) it.each([ @@ -545,10 +551,7 @@ describe(`clean_series`, () => { const y = [0, 10, 0, 10, 0, 10, 0, 10, 0, 10, 0, 10, 0, 10, 0, 10, 0, 10, 0, 10] const series: DataSeries = { x: y.map((_, idx) => idx), y } const result = clean_series(series, { smooth, in_place: false }) - const orig_var = y.reduce((sum, val) => sum + (val - 5) ** 2, 0) / y.length - const smooth_var = - result.series.y.reduce((sum, val) => sum + (val - 5) ** 2, 0) / result.series.y.length - expect(smooth_var).toBeLessThan(orig_var) + expect(variance(result.series.y, 5)).toBeLessThan(variance(y, 5)) }) it.each([ @@ -847,15 +850,8 @@ describe(`clean_xyz`, () => { expect(result.x).toEqual(x_input) // y and z should be smoothed (variance reduced) - const y_orig_var = y_input.reduce((sum, val) => sum + (val - 5) ** 2, 0) / y_input.length - const y_smooth_var = - result.y.reduce((sum, val) => sum + (val - 5) ** 2, 0) / result.y.length - expect(y_smooth_var).toBeLessThan(y_orig_var) - - const z_orig_var = z_input.reduce((sum, val) => sum + (val - 5) ** 2, 0) / z_input.length - const z_smooth_var = - result.z.reduce((sum, val) => sum + (val - 5) ** 2, 0) / result.z.length - expect(z_smooth_var).toBeLessThan(z_orig_var) + expect(variance(result.y, 5)).toBeLessThan(variance(y_input, 5)) + expect(variance(result.z, 5)).toBeLessThan(variance(z_input, 5)) }) // Regression: dynamic bounds must use x-axis value, not primary axis value diff --git a/tests/vitest/plot/layout.test.ts b/tests/vitest/plot/layout.test.ts index 0f41f5e0e..917496e4f 100644 --- a/tests/vitest/plot/layout.test.ts +++ b/tests/vitest/plot/layout.test.ts @@ -367,7 +367,8 @@ describe(`layout utility functions`, () => { padding: { t: 10, l: 80 }, default_padding: defaults, }) - expect(result).toEqual({ t: 10, l: 80, b: 60, r: 30 }) + // no y2 ticks -> r is the plain default (no tick-label/title reservation) + expect(result).toEqual({ t: 10, l: 80, b: 60, r: 20 }) }) it(`left padding is at least default when y-axis has ticks`, () => { @@ -442,5 +443,20 @@ describe(`layout utility functions`, () => { }) expect(with_label.t - without.t).toBe(AXIS_LABEL_HEIGHT) }) + + // y/y2 axis titles must reserve their rotated width too, else a wide tick label (e.g. + // "-789.389") pushes the title into the ticks (mirrors the x2 reservation above) + it.each([ + [`l`, `y_axis`], + [`r`, `y2_axis`], + ] as const)(`%s reserves AXIS_LABEL_HEIGHT for the %s title`, (side, axis_key) => { + const base = { padding: {}, default_padding: { t: 0, b: 0, l: 0, r: 0 } } + const without = calc_auto_padding({ ...base, [axis_key]: { tick_values: [1, 2] } }) + const with_label = calc_auto_padding({ + ...base, + [axis_key]: { tick_values: [1, 2], label: `Energy (eV)` }, + }) + expect(with_label[side] - without[side]).toBe(AXIS_LABEL_HEIGHT) + }) }) }) diff --git a/tests/vitest/plot/marginals.test.ts b/tests/vitest/plot/marginals.test.ts new file mode 100644 index 000000000..7d63865fd --- /dev/null +++ b/tests/vitest/plot/marginals.test.ts @@ -0,0 +1,774 @@ +import type { Vec2 } from '$lib/math' +import { + add_sides, + compute_marginal_curve, + curves_max, + default_marginal_label, + MARGINAL_DEFAULTS, + MARGINAL_HIT_TOLERANCE_PX, + marginal_hit, + marginal_strip_rect, + marginal_value_format, + marginal_value_scale, + normalize_marginals, + reserve_marginal_pad, +} from '$lib/plot/core/marginals' +import type { + MarginalSide, + MarginalCurve, + MarginalRenderContext, + MarginalSeriesCurve, + ResolvedMarginalConfig, +} from '$lib/plot/core/marginals' +import { describe, expect, test } from 'vitest' + +const resolved = (over: Partial = {}): ResolvedMarginalConfig => ({ + ...MARGINAL_DEFAULTS, + ...over, +}) + +// narrow a curve to its expected kind with a clear error (replaces hand-rolled guards) +const as_bars = (curve: MarginalCurve) => { + if (curve.kind !== `bars`) throw new Error(`expected bars curve, got ${curve.kind}`) + return curve +} +const as_line = (curve: MarginalCurve) => { + if (curve.kind !== `line`) throw new Error(`expected line curve, got ${curve.kind}`) + return curve +} +const sum_bins = (curve: MarginalCurve): number => + as_bars(curve).bins.reduce((sum, bin) => sum + bin.value, 0) + +describe(`normalize_marginals`, () => { + test.each([ + [`false disables all`, false, { top: true, right: true }, []], + [`undefined disables all`, undefined, { top: true, right: true }, []], + [`true uses default sides`, true, { top: true, right: true }, [`top`, `right`]], + [`per-side map`, { top: `cdf` }, {}, [`top`]], + [`explicit false overrides default`, { top: false }, { top: true }, []], + ] as const)(`%s`, (_desc, prop, defaults, active) => { + const result = normalize_marginals(prop, defaults) + const active_sides = (Object.keys(result) as MarginalSide[]).filter( + (side) => result[side] != null, + ) + expect(active_sides.sort()).toEqual([...active].sort()) + }) + + // a bare type string activates top+right (the default sides), even when none are passed + test.each([ + [`with default sides`, `kde`, { top: true, right: true }], + [`with no default sides`, `histogram`, {}], + ] as const)(`bare type string applies to top+right (%s)`, (_desc, type, defaults) => { + const result = normalize_marginals(type, { ...defaults }) + expect(result.top?.type).toBe(type) + expect(result.right?.type).toBe(type) + expect(result.bottom).toBeNull() + }) + + test(`user config merges over plot default config`, () => { + // Histogram-style default: cdf with bins 20; user overrides type but inherits bins + const result = normalize_marginals( + { top: `histogram` }, + { top: { type: `cdf`, bins: 20 } }, + ) + expect(result.top?.type).toBe(`histogram`) + expect(result.top?.bins).toBe(20) + }) + + test(`per-side map does not auto-activate unspecified default sides`, () => { + const result = normalize_marginals({ top: `kde` }, { top: true, right: true }) + expect(result.top?.type).toBe(`kde`) + expect(result.right).toBeNull() + expect(result.bottom).toBeNull() + expect(result.left).toBeNull() + }) + + test(`config fields fall back to MARGINAL_DEFAULTS`, () => { + const result = normalize_marginals({ left: { type: `kde` } }, {}) + expect(result.left).toMatchObject({ type: `kde`, size: 64, gap: 6, placement: `auto` }) + }) +}) + +describe(`reserve_marginal_pad`, () => { + test(`reserves size+gap per active side`, () => { + const resolved_marginals = normalize_marginals(true, { top: true, right: true }) + expect(reserve_marginal_pad(resolved_marginals)).toEqual({ t: 70, b: 0, l: 0, r: 70 }) + }) + + test(`custom sizes/gaps per side`, () => { + const resolved_marginals = normalize_marginals( + { bottom: { size: 40, gap: 4 }, left: { size: 100, gap: 10 } }, + {}, + ) + expect(reserve_marginal_pad(resolved_marginals)).toEqual({ t: 0, b: 44, l: 110, r: 0 }) + }) +}) + +test(`add_sides sums two padding objects`, () => { + expect(add_sides({ t: 5, b: 50, l: 60, r: 20 }, { t: 70, b: 0, l: 0, r: 70 })).toEqual({ + t: 75, + b: 50, + l: 60, + r: 90, + }) +}) + +describe(`marginal_strip_rect`, () => { + const pad = { t: 100, b: 80, l: 90, r: 84 } + const [width, height] = [400, 300] + const cfg = resolved({ size: 64, gap: 6 }) + + test.each([ + // side, has_axis -> auto resolves flush (no axis) vs outer (axis) + [`top`, false, { x: 90, y: 30, width: 226, height: 64 }], + [`top`, true, { x: 90, y: 0, width: 226, height: 64 }], + [`bottom`, false, { x: 90, y: 226, width: 226, height: 64 }], + [`bottom`, true, { x: 90, y: 236, width: 226, height: 64 }], + [`left`, false, { x: 20, y: 100, width: 64, height: 120 }], + [`left`, true, { x: 0, y: 100, width: 64, height: 120 }], + [`right`, false, { x: 322, y: 100, width: 64, height: 120 }], + [`right`, true, { x: 336, y: 100, width: 64, height: 120 }], + ] as const)(`%s side, has_axis=%s`, (side, has_axis, expected) => { + expect(marginal_strip_rect(side, pad, width, height, cfg, has_axis)).toEqual(expected) + }) + + test.each([ + [`flush`, true, 30], // placement forces flush even with an axis + [`outer`, false, 0], // placement forces outer even without an axis + ] as const)(`placement %s overrides auto`, (placement, has_axis, expected_y) => { + const rect = marginal_strip_rect( + `top`, + pad, + width, + height, + resolved({ size: 64, gap: 6, placement }), + has_axis, + ) + expect(rect.y).toBe(expected_y) + }) +}) + +describe(`marginal_value_scale`, () => { + // side, rect, domain, baseline, [value -> px] checks (value grows away from the plot edge) + test.each([ + [ + `top grows up from bottom baseline`, + `top`, + { x: 60, y: 0, width: 256, height: 64 }, + [0, 10], + 64, + [ + [0, 64], + [10, 0], + [5, 32], + ], + ], + [ + `bottom grows down from top baseline`, + `bottom`, + { x: 60, y: 220, width: 256, height: 64 }, + [0, 10], + 220, + [ + [0, 220], + [10, 284], + [5, 252], + ], + ], + [ + `right grows out from left baseline`, + `right`, + { x: 336, y: 100, width: 64, height: 120 }, + [0, 4], + 336, + [ + [4, 400], + [2, 368], + ], + ], + [ + `left grows out from right baseline`, + `left`, + { x: 0, y: 100, width: 64, height: 120 }, + [0, 8], + 64, + [[8, 0]], + ], + ] as const)(`%s`, (_desc, side, rect, domain, baseline, points) => { + const result = marginal_value_scale(side, { ...rect }, [domain[0], domain[1]]) + expect(result.baseline).toBe(baseline) + for (const [value, px] of points) expect(result.scale(value)).toBe(px) + }) +}) + +describe(`compute_marginal_curve`, () => { + const range: Vec2 = [0, 100] + + // bin values aggregate per normalization: raw counts, summed weights, or fractions summing to 1 + test.each([ + [ + `counts sum to sample count`, + Array.from({ length: 100 }, (_, idx) => idx), + undefined, + {}, + [0, 100], + 100, + ], + [`weights sum to total weight`, [1, 2, 3], [10, 20, 30], { bins: 4 }, [0, 4], 60], + [ + `probabilities sum to 1`, + Array.from({ length: 50 }, (_, idx) => idx), + undefined, + { normalize: `probability` }, + [0, 50], + 1, + ], + ] as const)(`histogram %s`, (_desc, positions, weights, over, range_in, expected) => { + const curve = compute_marginal_curve( + positions, + weights, + resolved(over), + [range_in[0], range_in[1]], + `linear`, + ) + expect(sum_bins(curve)).toBeCloseTo(expected, 6) + expect(as_bars(curve).max).toBeGreaterThan(0) + }) + + // density (unlike probability) integrates to 1: sum(value_i * bin_width_i) == 1 + test(`density normalization integrates to 1`, () => { + const positions = Array.from({ length: 10 }, (_, idx) => idx) + const curve = compute_marginal_curve( + positions, + undefined, + resolved({ type: `histogram`, bins: 10, normalize: `density` }), + [0, 10], + `linear`, + ) + const integral = as_bars(curve).bins.reduce( + (sum, bin) => sum + bin.value * (bin.pos1 - bin.pos0), + 0, + ) + expect(integral).toBeCloseTo(1, 6) + }) + + // every cdf is monotonic, ends at 1, and collapses tied positions; per case we pin the resulting + // positions (and exact values where weights matter). negative weights are dropped (they'd make the + // cumulative non-monotone); zero is kept harmlessly + test.each([ + [`sorts tied positions`, [3, 1, 2], undefined, range, [1, 2, 3], undefined], + [`reflects weights`, [1, 2], [1, 3], [0, 3], [1, 2], [0.25, 1]], + [`skips negative weights`, [1, 2, 3], [1, -5, 3], [0, 4], [1, 3], [0.25, 1]], + ] as const)(`cdf %s`, (_desc, positions, weights, range_in, expected_pos, expected_vals) => { + const curve = compute_marginal_curve( + positions, + weights, + resolved({ type: `cdf` }), + [range_in[0], range_in[1]], + `linear`, + ) + const { points, max } = as_line(curve) + expect(max).toBe(1) + expect(points.map((pt) => pt.pos)).toEqual(expected_pos) + const values = points.map((pt) => pt.value) + expect(values.at(-1)).toBeCloseTo(1, 6) + for (let idx = 1; idx < values.length; idx++) { + expect(values[idx]).toBeGreaterThanOrEqual(values[idx - 1]) + } + if (expected_vals) { + expected_vals.forEach((val, idx) => expect(values[idx]).toBeCloseTo(val, 6)) + } + }) + + // kde yields a finite, non-negative 100-point density line — including for zero-variance input, + // where the bandwidth floors to a positive value so the Gaussian kernel stays finite + test.each([ + [ + `varied data`, + Array.from({ length: 200 }, (_, idx) => Math.sin(idx) * 10 + 50), + [0, 100], + ], + [`zero-variance data`, Array.from({ length: 20 }, () => 5), [0, 10]], + ] as const)(`kde produces a valid density line for %s`, (_desc, positions, range_in) => { + const curve = compute_marginal_curve( + positions, + undefined, + resolved({ type: `kde` }), + [range_in[0], range_in[1]], + `linear`, + ) + const { points, max } = as_line(curve) + expect(points).toHaveLength(100) + expect(max).toBeGreaterThan(0) + expect(points.every((pt) => Number.isFinite(pt.value) && pt.value >= 0)).toBe(true) + }) + + test(`rug keeps finite positions only`, () => { + const curve = compute_marginal_curve( + [1, 2, NaN, 3, Infinity], + undefined, + resolved({ type: `rug` }), + range, + `linear`, + ) + if (curve.kind !== `rug`) throw new Error(`expected rug`) + expect(curve.positions).toEqual([1, 2, 3]) + }) + + test(`non-finite positions are filtered before binning`, () => { + const curve = compute_marginal_curve( + [1, NaN, 2, Infinity, -Infinity], + undefined, + resolved({ bins: 4 }), + [0, 4], + `linear`, + ) + expect(sum_bins(curve)).toBe(2) + }) + + test.each([`histogram`, `cdf`, `kde`, `rug`] as const)( + `%s returns an empty curve for empty input`, + (type) => { + const curve = compute_marginal_curve([], undefined, resolved({ type }), range, `linear`) + if (curve.kind === `bars`) expect(curve.bins).toHaveLength(0) + else if (curve.kind === `line`) expect(curve.points).toHaveLength(0) + else expect(curve.positions).toHaveLength(0) + }, + ) + + // every type must restrict to the current positional range so marginals track zoom/pan + test.each([ + [ + `histogram`, + (c: MarginalCurve) => + c.kind === `bars` ? c.bins.reduce((sum, bin) => sum + bin.value, 0) : -1, + 2, + ], + [`rug`, (c: MarginalCurve) => (c.kind === `rug` ? c.positions.length : -1), 2], + [`cdf`, (c: MarginalCurve) => (c.kind === `line` ? c.points.length : -1), 2], + ] as const)(`%s drops samples outside the positional range`, (type, measure, expected) => { + const curve = compute_marginal_curve( + [1, 2, 100], + undefined, + resolved({ type }), + [0, 10], + `linear`, + ) + expect(measure(curve)).toBe(expected) + }) + + test(`cdf over a zoomed range still ends at 1`, () => { + const curve = compute_marginal_curve( + [1, 2, 100], + undefined, + resolved({ type: `cdf` }), + [0, 10], + `linear`, + ) + const { points } = as_line(curve) + expect(points.map((pt) => pt.pos)).toEqual([1, 2]) + expect(points[points.length - 1].value).toBeCloseTo(1, 6) + }) + + test(`log kde grid spans the view range, not the smallest sample`, () => { + // range[0] = 1 > 0, so no clamping: the grid must start at 1, not at the min sample (10) + const curve = compute_marginal_curve( + [10, 20], + undefined, + resolved({ type: `kde` }), + [1, 100], + `log`, + ) + expect(as_line(curve).points[0].pos).toBeCloseTo(1, 6) + }) + + test(`log axis drops non-positive positions`, () => { + const curve = compute_marginal_curve( + [-5, 0, 10], + undefined, + resolved({ bins: 4 }), + [0.001, 100], + `log`, + ) + expect(sum_bins(curve)).toBe(1) + }) + + // a degenerate log range (lower bound <= 0) must clamp the histogram bin domain to the smallest + // positive sample, so no bin spans non-positive (non-renderable) positions + test(`log histogram clamps the bin domain to positive on a degenerate range`, () => { + const curve = compute_marginal_curve( + [10, 20, 30], + undefined, + resolved({ bins: 4 }), + [-5, 100], + `log`, + ) + const { bins } = as_bars(curve) + expect(bins.length).toBeGreaterThan(0) + expect(Math.min(...bins.map((bin) => bin.pos0))).toBeGreaterThanOrEqual(10) + }) + + // a reversed range (inverted axis) must not crash d3.bin or empty the kde grid; the range + // is canonicalized so the result is identical to the ascending range + test.each([`histogram`, `kde`, `cdf`] as const)( + `%s handles a reversed (descending) positional range`, + (type) => { + const positions = [10, 20, 30, 40, 50] + const ascending = compute_marginal_curve( + positions, + undefined, + resolved({ type }), + [0, 100], + `linear`, + ) + const descending = compute_marginal_curve( + positions, + undefined, + resolved({ type }), + [100, 0], + `linear`, + ) + expect(descending).toEqual(ascending) + if (descending.kind === `bars`) expect(descending.bins.length).toBeGreaterThan(0) + if (descending.kind === `line`) expect(descending.points.length).toBeGreaterThan(0) + }, + ) +}) + +test(`curves_max returns the max across curves, ignoring rug`, () => { + const curves: MarginalCurve[] = [ + { kind: `bars`, bins: [], max: 3 }, + { kind: `line`, points: [], max: 7 }, + { kind: `rug`, positions: [1, 2, 3] }, + ] + expect(curves_max(curves)).toBe(7) +}) + +describe(`value-axis label + format`, () => { + // type, normalize -> auto title (default_marginal_label) + tick format (marginal_value_format) + test.each([ + [`cdf`, undefined, `CDF`, `.0%`], + [`kde`, undefined, `density`, `.2~g`], + [`histogram`, undefined, `count`, `.3~s`], + [`histogram`, `density`, `density`, `.2~g`], + [`histogram`, `probability`, `probability`, `.0%`], + [`rug`, undefined, ``, `.2~g`], + ] as const)(`%s (normalize=%s): label=%s, format=%s`, (type, normalize, label, format) => { + const cfg = resolved({ type, normalize }) + expect(default_marginal_label(cfg)).toBe(label) + expect(marginal_value_format(cfg)).toBe(format) + }) + + test(`explicit label overrides the auto title`, () => { + expect(default_marginal_label(resolved({ type: `cdf`, label: `Cumulative` }))).toBe( + `Cumulative`, + ) + }) +}) + +describe(`marginal_hit`, () => { + // A `top` strip (is_x): positional coord = px (scale: data*10), cross coord = py. value_scale + // grows up from baseline 64 (value*6), matching marginal_value_scale for a top strip. + const make_ctx = ( + curves: MarginalSeriesCurve[], + over: Partial = {}, + ): MarginalRenderContext => ({ + config: resolved(), + side: `top`, + positional_range: [0, 10], + scale_type: `linear`, + rect: { x: 0, y: 0, width: 100, height: 64 }, + positional_scale: (val: number) => val * 10, + value_scale: (val: number) => 64 - val * 6, + baseline: 64, + curves, + series: [], + ...over, + }) + + const bars_curve = ( + bins: { pos0: number; pos1: number; value: number }[], + color = `red`, + label?: string, + ): MarginalSeriesCurve => ({ + series_idx: 0, + color, + label, + curve: { kind: `bars`, bins, max: Math.max(0, ...bins.map((bin) => bin.value)) }, + }) + + const line_curve = ( + points: { pos: number; value: number }[], + color = `green`, + label?: string, + ): MarginalSeriesCurve => ({ + series_idx: 0, + color, + label, + curve: { kind: `line`, points, max: Math.max(0, ...points.map((pt) => pt.value)) }, + }) + + test(`bars: pointer inside a bin returns that bin`, () => { + const ctx = make_ctx([ + bars_curve([ + { pos0: 0, pos1: 5, value: 3 }, + { pos0: 5, pos1: 10, value: 8 }, + ]), + ]) + // px=25 -> bin [0,5] (px span [0,50]); py near baseline so cross is inside the bar + const hit = marginal_hit(ctx, 25, 60) + expect(hit?.kind).toBe(`bars`) + expect(hit?.pos0).toBe(0) + expect(hit?.pos1).toBe(5) + expect(hit?.value).toBe(3) + expect(hit?.pos).toBe(2.5) + // px=75 -> bin [5,10] + expect(marginal_hit(ctx, 75, 60)?.value).toBe(8) + }) + + // px=150 is beyond the bin's [0,50] px span; px=25/py=30 sits inside the column but above the + // rendered bar (value=3 spans py 46..64) + test.each([ + [`beyond the bin span`, 150, 60], + [`inside the column but above the bar`, 25, 30], + ] as const)(`bars: pointer %s returns null`, (_desc, px, py) => { + const ctx = make_ctx([bars_curve([{ pos0: 0, pos1: 5, value: 3 }])]) + expect(marginal_hit(ctx, px, py)).toBeNull() + }) + + test(`bars: overlaid series resolve to the tallest bar`, () => { + const ctx = make_ctx([ + bars_curve([{ pos0: 0, pos1: 5, value: 2 }], `red`, `low`), + bars_curve([{ pos0: 0, pos1: 5, value: 9 }], `blue`, `high`), + ]) + const hit = marginal_hit(ctx, 25, 30) + expect(hit?.value).toBe(9) + expect(hit?.label).toBe(`high`) + expect(hit?.color).toBe(`blue`) + }) + + test(`line: returns the nearest point by positional distance`, () => { + const ctx = make_ctx([ + line_curve( + [ + { pos: 0, value: 0 }, + { pos: 5, value: 0.5 }, + { pos: 10, value: 1 }, + ], + `green`, + `kde`, + ), + ]) + const hit = marginal_hit(ctx, 48, 62) // px=48 -> nearest pos 5 (px 50); py inside fill + expect(hit?.kind).toBe(`line`) + expect(hit?.pos).toBe(5) + expect(hit?.value).toBe(0.5) + }) + + test(`line: pointer outside every filled curve returns null`, () => { + const ctx = make_ctx([ + line_curve( + [ + { pos: 4, value: 0.5 }, + { pos: 6, value: 0.5 }, + ], + `green`, + `kde`, + ), + ]) + // value=0.5 spans py 61..64, so py=30 is inside the strip but outside the rendered fill. + expect(marginal_hit(ctx, 50, 30)).toBeNull() + }) + + // Among overlaid fills containing the pointer, the OUTERMOST (curve reaching furthest from the + // baseline) wins — the curve the pointer visually sits within. Regression for the box-plot/KDE + // marginal whose tooltip showed the wrong series when hovering a taller curve's translucent fill. + test(`line: the outermost fill under the pointer wins, even over a lower curve's line`, () => { + const ctx = make_ctx([ + line_curve( + [ + { pos: 4, value: 0.2 }, + { pos: 6, value: 0.2 }, + ], + `red`, + `A`, + ), + line_curve( + [ + { pos: 4, value: 0.9 }, + { pos: 6, value: 0.9 }, + ], + `blue`, + `B`, + ), + ]) + // value_scale = 64 - val*6, baseline 64: A's line at py 62.8, B's at py 58.6. B's fill reaches + // furthest (py 58.6..64), so a pointer at py 63 sits on A's line but inside B's outer fill -> B. + expect(marginal_hit(ctx, 50, 63)?.label).toBe(`B`) + }) + + // a curve the pointer is NOT inside must not win, even with a larger absolute extent. This guards + // the containment check for custom (reduce) line curves with signed values: fills land on opposite + // sides of the baseline and don't nest, so "tallest" alone would pick the wrong (unhovered) curve. + test(`line: a curve on the opposite side of the baseline never wins`, () => { + const ctx = make_ctx([ + line_curve( + [ + { pos: 4, value: 0.5 }, + { pos: 6, value: 0.5 }, + ], + `red`, + `A`, + ), + line_curve( + [ + { pos: 4, value: -0.8 }, + { pos: 6, value: -0.8 }, + ], + `blue`, + `B`, + ), + ]) + // value_scale = 64 - v*6, baseline 64: A's fill is py 61..64 (above), B's is 64..68.8 (below). + // A pointer at py 61 is inside A's fill only, so A wins despite B's larger absolute extent. + expect(marginal_hit(ctx, 50, 61)?.label).toBe(`A`) + }) + + test.each([ + [`within tolerance`, 22, 2], + [`beyond tolerance returns null`, 45, null], + ])(`rug: nearest tick %s`, (_desc, px, expected) => { + const ctx = make_ctx([ + { + series_idx: 0, + color: `gray`, + curve: { kind: `rug`, positions: [2, 7] }, // px 20, 70 + }, + ]) + const hit = marginal_hit(ctx, px, 50) + expect(hit?.pos ?? null).toBe(expected) + }) + + // a `left` strip (is_x=false): positional coord = py, value runs along x. marginal_hit must use py + test(`left/right strip uses the cross (y) coord as the positional axis`, () => { + const ctx = make_ctx( + [ + bars_curve([ + { pos0: 0, pos1: 5, value: 3 }, + { + pos0: 5, + pos1: 10, + value: 8, + }, + ]), + ], + { side: `left` }, + ) + // py=75 -> bin [5,10] (py span [50,100]); px=30 sits inside the value fill. + const hit = marginal_hit(ctx, 30, 75) + expect(hit?.pos0).toBe(5) + expect(hit?.value).toBe(8) + }) + + // hit-testing skips non-finite data so custom reduce/data curves can't yield ghost hits (matching + // the renderer, which filters non-finite primitives) + test.each([ + [`NaN edges`, [{ pos0: NaN, pos1: NaN, value: 5 }]], + [`Infinity value`, [{ pos0: 0, pos1: 5, value: Infinity }]], + ])(`bars: a non-finite bin is skipped (%s)`, (_desc, bins) => { + expect(marginal_hit(make_ctx([bars_curve(bins)]), 25, 60)).toBeNull() + }) + + // each series is the outermost fill where it peaks, so each remains selectable there + test(`line: each overlaid series is selectable where its fill is on top`, () => { + const ctx = make_ctx([ + line_curve( + [ + { pos: 2, value: 0.9 }, + { pos: 8, value: 0.1 }, + ], + `red`, + `A`, + ), + line_curve( + [ + { pos: 2, value: 0.1 }, + { pos: 8, value: 0.9 }, + ], + `blue`, + `B`, + ), + ]) + // A peaks left (pos 2), B peaks right (pos 8); at py 59 only the peaking series' fill reaches + expect(marginal_hit(ctx, 20, 59)?.label).toBe(`A`) + expect(marginal_hit(ctx, 80, 59)?.label).toBe(`B`) + }) + + test(`empty curves return null`, () => { + expect(marginal_hit(make_ctx([]), 25, 30)).toBeNull() + }) + + test(`line: a leading non-finite point does not poison the search`, () => { + // two finite points (renderer/hit need >= 2); the NaN point must be skipped, not break the scan + const ctx = make_ctx([ + line_curve([ + { pos: NaN, value: 1 }, + { pos: 4, value: 0.5 }, + { pos: 6, value: 0.5 }, + ]), + ]) + const hit = marginal_hit(ctx, 42, 62) // px=42 -> nearest finite pos 4; py inside fill + expect(hit?.pos).toBe(4) + expect(hit?.value).toBe(0.5) + }) + + test(`rug: a non-finite tick is skipped (no false in-tolerance hit)`, () => { + const ctx = make_ctx([ + { series_idx: 0, color: `gray`, curve: { kind: `rug`, positions: [Infinity] } }, + ]) + expect(marginal_hit(ctx, 25, 50)).toBeNull() + }) + + // a ctx field (config.color override, ctx.format) reaches the hover payload at the matched bin + test.each([ + [ + `config.color overrides the per-series color`, + { config: resolved({ color: `purple` }) }, + `color`, + `purple`, + ], + [`format from ctx is forwarded to the hover payload`, { format: `.2f` }, `format`, `.2f`], + ] as const)(`%s`, (_desc, over, field, expected) => { + const ctx = make_ctx([bars_curve([{ pos0: 0, pos1: 5, value: 3 }], `red`)], over) + expect(marginal_hit(ctx, 25, 60)?.[field]).toBe(expected) + }) + + test(`tick_label maps the matched pos to a categorical label`, () => { + const ctx = make_ctx( + [ + line_curve([ + { pos: 0, value: 0.3 }, + { pos: 1, value: 0.7 }, + ]), + ], + { tick_label: (pos) => [`Cubic`, `Hexagonal`][Math.round(pos)] }, + ) + expect(marginal_hit(ctx, 9, 62)?.pos_label).toBe(`Hexagonal`) // px=9 -> pos 1, py inside fill + }) + + test(`axis_title threads through to the hover payload`, () => { + const curve = line_curve([ + { pos: 4, value: 0.5 }, + { pos: 6, value: 0.5 }, + ]) + expect(marginal_hit(make_ctx([curve], { axis_title: `Error` }), 50, 62)?.axis_title).toBe( + `Error`, + ) + // absent axis_title leaves the field undefined (PlotMarginals falls back to "pos"/"range") + expect(marginal_hit(make_ctx([curve]), 50, 62)?.axis_title).toBeUndefined() + }) + + test(`MARGINAL_HIT_TOLERANCE_PX is a positive pixel threshold`, () => { + expect(MARGINAL_HIT_TOLERANCE_PX).toBeGreaterThan(0) + }) +}) diff --git a/tests/vitest/structure/CellSelect.test.svelte.ts b/tests/vitest/structure/CellSelect.test.svelte.ts index d704a1645..28bd5d19b 100644 --- a/tests/vitest/structure/CellSelect.test.svelte.ts +++ b/tests/vitest/structure/CellSelect.test.svelte.ts @@ -3,7 +3,7 @@ import type { CellType } from '$lib/symmetry' import type { MoyoDataset } from '@spglib/moyo-wasm' import { mount, tick } from 'svelte' import { describe, expect, test } from 'vitest' -import { doc_query } from '../setup' +import { bind_props, doc_query } from '../setup' // Mock sym_data for testing cell type buttons const mock_sym_data = { @@ -209,20 +209,19 @@ describe(`CellSelect`, () => { ] as const)( `clicking Prim button with sym_data=%s results in cell_type=%s`, async (sym_data, expected) => { - let cell_type = $state(`original`) - await mount_and_open({ - supercell_scaling: `1x1x1`, - sym_data, - get cell_type() { - return cell_type - }, - set cell_type(val) { - cell_type = val - }, - }) + const state = $state({ cell_type: `original` as CellType }) + await mount_and_open( + bind_props( + { + supercell_scaling: `1x1x1`, + sym_data, + }, + state, + ), + ) document.querySelectorAll(`.cell-type-btn`)[1].click() await tick() - expect(cell_type).toBe(expected) + expect(state.cell_type).toBe(expected) }, ) }) @@ -249,15 +248,8 @@ describe(`CellSelect`, () => { }) test(`clicking preset updates scaling and closes menu`, async () => { - let scaling = $state(`1x1x1`) - await mount_and_open({ - get supercell_scaling() { - return scaling - }, - set supercell_scaling(val) { - scaling = val - }, - }) + const state = $state({ supercell_scaling: `1x1x1` }) + await mount_and_open(bind_props({}, state)) const btn = Array.from(document.querySelectorAll(`.preset-btn`)).find( (button_elem) => normalize_supercell_label(button_elem.textContent) === `2x2x2`, @@ -266,7 +258,7 @@ describe(`CellSelect`, () => { btn?.click() await tick() - expect(scaling).toBe(`2x2x2`) + expect(state.supercell_scaling).toBe(`2x2x2`) expect(document.querySelector(`.dropdown`)).toBeNull() }) }) @@ -322,15 +314,8 @@ describe(`CellSelect`, () => { ])( `%s with input="%s" results in scaling="%s", menu_open=%s`, async (method, input_val, expected_scaling, menu_stays_open) => { - let scaling = $state(`1x1x1`) - await mount_and_open({ - get supercell_scaling() { - return scaling - }, - set supercell_scaling(val) { - scaling = val - }, - }) + const state = $state({ supercell_scaling: `1x1x1` }) + await mount_and_open(bind_props({}, state)) const input = doc_query(`.custom-input-row input`) input.value = input_val @@ -344,7 +329,7 @@ describe(`CellSelect`, () => { } await tick() - expect(scaling).toBe(expected_scaling) + expect(state.supercell_scaling).toBe(expected_scaling) if (menu_stays_open) { expect(document.querySelector(`.dropdown`)).toBeInstanceOf(HTMLElement) } else { @@ -379,34 +364,23 @@ describe(`CellSelect`, () => { }) test(`toggle button updates when props change`, async () => { - let scaling = $state(`1x1x1`) - let cell_type = $state(`original`) + const state = $state({ + supercell_scaling: `1x1x1`, + cell_type: `original` as CellType, + }) mount(CellSelect, { target: document.body, - props: { - get supercell_scaling() { - return scaling - }, - set supercell_scaling(val) { - scaling = val - }, - get cell_type() { - return cell_type - }, - set cell_type(val) { - cell_type = val - }, - }, + props: bind_props({}, state), }) const toggle = doc_query(`.toggle-btn`) expect(normalize_supercell_label(toggle.textContent)).toBe(`1x1x1`) - scaling = `2x2x2` + state.supercell_scaling = `2x2x2` await tick() expect(normalize_supercell_label(toggle.textContent)).toBe(`2x2x2`) - cell_type = `primitive` + state.cell_type = `primitive` await tick() expect(normalize_supercell_label(toggle.textContent)).toBe(`Prim 2x2x2`) }) diff --git a/tests/vitest/structure/Structure.test.svelte.ts b/tests/vitest/structure/Structure.test.svelte.ts index df510bd59..d54de73a0 100644 --- a/tests/vitest/structure/Structure.test.svelte.ts +++ b/tests/vitest/structure/Structure.test.svelte.ts @@ -1044,6 +1044,50 @@ describe(`Structure string parsing`, () => { }) }) +// Multi-side view (2x2 grid). The canvas grid itself is gated behind a +// `typeof WebGLRenderingContext !== 'undefined'` guard so it doesn't render in +// happy-dom; these cover the toggle button + wrapper class. The 4-canvas render +// and independent rotation are exercised by the playwright suite. +describe(`Multi-side view`, () => { + test(`toggle button renders and flips multi_view + wrapper class`, async () => { + mount_structure({ structure, show_controls: `always` }) + await tick() + + const toggle = doc_query(`button.multi-view-toggle`) + expect(toggle).toBeInstanceOf(HTMLButtonElement) + expect(toggle.getAttribute(`aria-pressed`)).toBe(`false`) + expect(doc_query(`.structure`).classList.contains(`multi-view`)).toBe(false) + + toggle.click() + flushSync() + await tick() + + expect(toggle.getAttribute(`aria-pressed`)).toBe(`true`) + expect(doc_query(`.structure`).classList.contains(`multi-view`)).toBe(true) + + toggle.click() + flushSync() + await tick() + expect(toggle.getAttribute(`aria-pressed`)).toBe(`false`) + expect(doc_query(`.structure`).classList.contains(`multi-view`)).toBe(false) + }) + + test(`multi_view prop initial value reflects on wrapper`, async () => { + mount_structure({ structure, multi_view: true, show_controls: `always` }) + await tick() + expect(doc_query(`.structure`).classList.contains(`multi-view`)).toBe(true) + }) + + test(`toggle button is hidden when 'multi-view' control is in hidden list`, async () => { + mount_structure({ + structure, + show_controls: { mode: `always`, hidden: [`multi-view`] }, + }) + await tick() + expect(document.querySelector(`button.multi-view-toggle`)).toBeNull() + }) +}) + // Regression tests for camera rotation center bug (commit 10477bb9 introduced // scene_props.camera_target for comparison-view sync, but it persisted across // structure loads, causing the orbit center to shift to a corner of the new cell). diff --git a/tests/vitest/structure/bonding.test.ts b/tests/vitest/structure/bonding.test.ts index 4b64c32b0..175b088c1 100644 --- a/tests/vitest/structure/bonding.test.ts +++ b/tests/vitest/structure/bonding.test.ts @@ -32,6 +32,14 @@ const make_random_structure = (n_atoms: number): Crystal => { ) } +// Find the bond between two site indices regardless of stored order +const find_bond = (bonds: BondPair[], idx_a: number, idx_b: number): BondPair | undefined => + bonds.find( + (bond) => + (bond.site_idx_1 === idx_a && bond.site_idx_2 === idx_b) || + (bond.site_idx_1 === idx_b && bond.site_idx_2 === idx_a), + ) + describe(`Bonding Algorithms`, () => { const algorithms: [BondingAlgo, BondingStrategy, Vec2[]][] = [ [ @@ -843,135 +851,25 @@ test(`electroneg_ratio treats original and image atoms symmetrically`, () => { // 6. It sees "Short" neighbor. Distance 2.0. Accepted. // Result: Image Na has 1 bond (or 2 bonds with different strengths). - const Na_props = { element: `Na` as const, occu: 1, oxidation_state: 0 } - const Cl_props = { element: `Cl` as const, occu: 1, oxidation_state: 0 } - - const structure: Crystal = { - sites: [ - // 0: Original Na - { - species: [Na_props], - abc: [0, 0, 0], - xyz: [0, 0, 0], - label: `Na`, - properties: { orig_site_idx: 0 }, - }, - // 1: Cl at distance 3.0 (Long) - { - species: [Cl_props], - abc: [0, 0, 0], - xyz: [3.0, 0, 0], - label: `Cl_long`, - properties: { orig_site_idx: 1 }, - }, - // 2: Cl at distance 2.0 (Short) - { - species: [Cl_props], - abc: [0, 0, 0], - xyz: [0, 2.0, 0], - label: `Cl_short`, - properties: { orig_site_idx: 2 }, - }, - - // 3: Image Na (shifted by 10 in y, but we manually place neighbors relative to it to mimic periodicity) - // Actually, we can just place it far away and give it its own neighbors with same local geometry - { - species: [Na_props], - abc: [0, 0, 0], - xyz: [100, 0, 0], - label: `Na_img`, - properties: { orig_site_idx: 0 }, - }, - // 4: Image Cl Long (relative to Na_img: +3.0 x) - { - species: [Cl_props], - abc: [0, 0, 0], - xyz: [103.0, 0, 0], - label: `Cl_long_img`, - properties: { orig_site_idx: 1 }, - }, - // 5: Image Cl Short (relative to Na_img: +2.0 y) - { - species: [Cl_props], - abc: [0, 0, 0], - xyz: [100, 2.0, 0], - label: `Cl_short_img`, - properties: { orig_site_idx: 2 }, - }, - ], - lattice: { - matrix: [ - [1000, 0, 0], - [0, 1000, 0], - [0, 0, 1000], - ], - pbc: [true, true, true], - a: 1000, - b: 1000, - c: 1000, - alpha: 90, - beta: 90, - gamma: 90, - volume: 1e9, - }, - } - - // We expect bonds: - // 0-1 (dist 3.0), 0-2 (dist 2.0) - // 3-4 (dist 3.0), 3-5 (dist 2.0) - - // To trigger the penalty effectively, we need the penalty to push strength below threshold (default 0.3). - // Base strength for Na-Cl is high (metal-nonmetal bonus 1.5, en diff ~2.1 > 1.7 -> 1.3 bonus). - // Na(0.93) Cl(3.16). Diff=2.23. - // Strength ~= 1.0 * 1.5 (metal-nonmetal) * 1.3 (en_diff) = 1.95. - // Distance weight: dist/expected. Na(1.54)+Cl(0.99) = 2.53. - // Long (3.0): ratio = 3.0/2.53 = 1.18. Weight = exp(-((1.18-1)^2)/0.18) = exp(-0.032/0.18) = exp(-0.18) ~= 0.83. - // Short (2.0): ratio = 2.0/2.53 = 0.79. Weight = exp(-((0.79-1)^2)/0.18) = exp(-0.044/0.18) = exp(-0.24) ~= 0.78. - // - // Strength Long ~= 1.95 * 0.83 = 1.6. - // Strength Short ~= 1.95 * 0.78 = 1.5. - // - // Penalty: if dist > closest. - // closest = 2.0. - // Long (3.0) > 2.0. Ratio 1.5. - // Penalty = exp(-(1.5 - 1) / 0.5) = exp(-0.5 / 0.5) = exp(-1) = 0.36. - // Penalized Long Strength = 1.6 * 0.36 = 0.57. - // Still > 0.3 threshold? - // - // Let's adjust distances to make penalty more severe or initial strength lower. - // Or increase threshold. - - const options = { - strength_threshold: 0.6, // Increase threshold so penalized bond drops below - } - - const bonds = bonding.electroneg_ratio(structure, options) - - const bonds_orig = bonds.filter((bond) => bond.site_idx_1 === 0 || bond.site_idx_2 === 0) - const bonds_img = bonds.filter((bond) => bond.site_idx_1 === 3 || bond.site_idx_2 === 3) - - // If bug exists: - // Orig will have 2 bonds (Short + Long, because Long processed first) - // Img will have 1 bond (Short only, because Long processed after Short was known via Orig) - - // Sort bonds by length for easier debugging - bonds_orig.sort((a, b) => a.bond_length - b.bond_length) - bonds_img.sort((a, b) => a.bond_length - b.bond_length) - - expect(bonds_img).toHaveLength(bonds_orig.length) - - // If fixed, both should have 1 bond (because the penalty is now applied to both) - // Or both have 2 (if penalty wasn't strong enough). - // In our case, we tuned threshold so penalty should remove Long bond. - // Wait, if penalty removes Long bond, then both should have 1 bond. - // With bug: Orig had 2, Img had 1. - // With fix: Both should have 1 (consistent). + // Two copies of identical local geometry (a Na with a "Long" 3.0 A and a "Short" 2.0 A Cl + // neighbor): sites 0-2 are originals, 3-5 are images (orig_site_idx 0,1,2) placed 100 A away. + const structure = make_crystal(1000, [ + { element: `Na`, xyz: [0, 0, 0], properties: { orig_site_idx: 0 } }, + { element: `Cl`, xyz: [3, 0, 0], properties: { orig_site_idx: 1 } }, // Long (3.0 A) + { element: `Cl`, xyz: [0, 2, 0], properties: { orig_site_idx: 2 } }, // Short (2.0 A) + { element: `Na`, xyz: [100, 0, 0], properties: { orig_site_idx: 0 } }, + { element: `Cl`, xyz: [103, 0, 0], properties: { orig_site_idx: 1 } }, // Long image + { element: `Cl`, xyz: [100, 2, 0], properties: { orig_site_idx: 2 } }, // Short image + ]) - // Actually, the "fix" ensures that original also sees the penalty from the short bond - // because we calculate closest distances for all pairs BEFORE applying penalties. - // So the Short bond (2.0) will penalize the Long bond (3.0) even for the original atom. + // Threshold tuned so the Long-bond penalty (applied once closest=2.0 is known) drops it below + // threshold. Pre-fix the original kept 2 bonds (it saw Long before Short set closest) while the + // image kept 1; the fix gathers all closest distances before penalizing, so both bond the same. + const bonds = bonding.electroneg_ratio(structure, { strength_threshold: 0.6 }) + const bond_count = (anchor: number) => + bonds.filter((bond) => bond.site_idx_1 === anchor || bond.site_idx_2 === anchor).length - expect(bonds_orig).toHaveLength(bonds_img.length) + expect(bond_count(3)).toBe(bond_count(0)) // image (idx 3) bonds identically to original (idx 0) }) test(`electroneg_ratio preserves longer C-C bonds in presence of shorter C-H bonds`, () => { // Benzene-like fragment: C bonded to C and H. @@ -988,69 +886,17 @@ test(`electroneg_ratio preserves longer C-C bonds in presence of shorter C-H bon // C-C is the "closest" in normalized space. C-H is slightly further. // Both should survive. - const C_props = { element: `C` as const, occu: 1, oxidation_state: 0 } - const H_props = { element: `H` as const, occu: 1, oxidation_state: 0 } - - const structure: Crystal = { - sites: [ - // Central C - { - species: [C_props], - abc: [0, 0, 0], - xyz: [0, 0, 0], - label: `C1`, - properties: { orig_site_idx: 0 }, - }, - // Neighbor H (1.09 A away) - { - species: [H_props], - abc: [0, 0, 0], - xyz: [1.09, 0, 0], - label: `H1`, - properties: { orig_site_idx: 1 }, - }, - // Neighbor C (1.40 A away) - { - species: [C_props], - abc: [0, 0, 0], - xyz: [0, 1.4, 0], - label: `C2`, - properties: { orig_site_idx: 2 }, - }, - ], - lattice: { - matrix: [ - [10, 0, 0], - [0, 10, 0], - [0, 0, 10], - ], - pbc: [true, true, true], - a: 10, - b: 10, - c: 10, - alpha: 90, - beta: 90, - gamma: 90, - volume: 1000, - }, - } + const structure = make_crystal(10, [ + { element: `C`, xyz: [0, 0, 0] }, // central C + { element: `H`, xyz: [1.09, 0, 0] }, // C-H at 1.09 A (shorter raw distance) + { element: `C`, xyz: [0, 1.4, 0] }, // C-C at 1.40 A (closer in normalized space) + ]) const bonds = bonding.electroneg_ratio(structure) - // We expect C1-H1 and C1-C2. - const c_h = bonds.find( - (bond) => - (bond.site_idx_1 === 0 && bond.site_idx_2 === 1) || - (bond.site_idx_1 === 1 && bond.site_idx_2 === 0), - ) - const c_c = bonds.find( - (bond) => - (bond.site_idx_1 === 0 && bond.site_idx_2 === 2) || - (bond.site_idx_1 === 2 && bond.site_idx_2 === 0), - ) - - expect(c_h).toBeDefined() - expect(c_c).toBeDefined() + // Both C1-H1 and C1-C2 must survive (normalized distance keeps the longer C-C bond) + expect(find_bond(bonds, 0, 1)).toBeDefined() + expect(find_bond(bonds, 0, 2)).toBeDefined() }) test(`bonding logic treats original and image atoms consistently`, () => { @@ -1099,73 +945,18 @@ test(`bonding logic treats original and image atoms consistently`, () => { }) test(`electroneg_ratio ignores weak bonds for closest neighbor penalty`, () => { - const Na_props = { element: `Na` as const, occu: 1, oxidation_state: 0 } - const Cl_props = { element: `Cl` as const, occu: 1, oxidation_state: 0 } - - const structure: Crystal = { - sites: [ - { - species: [Na_props], - abc: [0, 0, 0], - xyz: [0, 0, 0], - label: `Na`, - properties: { orig_site_idx: 0 }, - }, - // Weak neighbor (metal-metal, same species) at short distance - { - species: [Na_props], - abc: [0, 0, 0], - xyz: [2.0, 0, 0], - label: `Na_weak`, - properties: { orig_site_idx: 1 }, - }, - // Strong neighbor (ionic) at longer distance - { - species: [Cl_props], - abc: [0, 0, 0], - xyz: [0, 3.0, 0], - label: `Cl_strong`, - properties: { orig_site_idx: 2 }, - }, - ], - lattice: { - matrix: [ - [10, 0, 0], - [0, 10, 0], - [0, 0, 10], - ], - pbc: [true, true, true], - a: 10, - b: 10, - c: 10, - alpha: 90, - beta: 90, - gamma: 90, - volume: 1000, - }, - } - - // Na-Na (2.0A): weak -> should be rejected by threshold - // Na-Cl (3.0A): strong -> should be accepted - // If Na-Na incorrectly sets "closest" distance despite being rejected, - // Na-Cl gets penalized heavily and might be rejected too. - const bonds = bonding.electroneg_ratio(structure, { - strength_threshold: 0.4, - }) + const structure = make_crystal(10, [ + { element: `Na`, xyz: [0, 0, 0] }, + { element: `Na`, xyz: [2, 0, 0] }, // weak (metal-metal, same species), short 2.0 A + { element: `Cl`, xyz: [0, 3, 0] }, // strong (ionic), longer 3.0 A + ]) - const na_na = bonds.find( - (bond) => - (bond.site_idx_1 === 0 && bond.site_idx_2 === 1) || - (bond.site_idx_1 === 1 && bond.site_idx_2 === 0), - ) - const na_cl = bonds.find( - (bond) => - (bond.site_idx_1 === 0 && bond.site_idx_2 === 2) || - (bond.site_idx_1 === 2 && bond.site_idx_2 === 0), - ) + // Na-Na (2.0 A) is weak -> rejected by threshold; Na-Cl (3.0 A) is strong -> accepted. If the + // rejected Na-Na wrongly set the "closest" distance, Na-Cl would be over-penalized and dropped. + const bonds = bonding.electroneg_ratio(structure, { strength_threshold: 0.4 }) - expect(na_na).toBeUndefined() - expect(na_cl).toBeDefined() + expect(find_bond(bonds, 0, 1)).toBeUndefined() // weak Na-Na rejected + expect(find_bond(bonds, 0, 2)).toBeDefined() // strong Na-Cl survives }) describe(`remap_bonds_after_deletion`, () => { @@ -1191,3 +982,54 @@ describe(`remap_bonds_after_deletion`, () => { expect(bonding.remap_bonds_after_deletion(bonds, new Set(deleted))).toEqual(expected) }) }) + +describe(`compute_bonds memo`, () => { + const structure = get_test_structure([ + { xyz: [0, 0, 0], element: `Fe` }, + { xyz: [2, 0, 0], element: `O` }, + { xyz: [4, 0, 0], element: `C` }, + ]) + + test(`matches the underlying strategy result`, () => { + expect(bonding.compute_bonds(structure, `electroneg_ratio`)).toEqual( + bonding.electroneg_ratio(structure), + ) + }) + + test(`repeated identical calls reuse the cached array (multi-view dedup)`, () => { + // simulate the 4 panes computing bonds for the same structure in one flush + const first = bonding.compute_bonds(structure, `electroneg_ratio`, {}) + const second = bonding.compute_bonds(structure, `electroneg_ratio`, {}) + expect(second).toBe(first) // same reference => no recompute + }) + + const other_structure = get_test_structure([{ xyz: [0, 0, 0] }]) + test.each([ + [`different structure`, other_structure, `electroneg_ratio`, {}], + [`different strategy`, structure, `solid_angle`, {}], + [`different options`, structure, `electroneg_ratio`, { max_distance_ratio: 3 }], + ] as const)(`recomputes on %s`, (_desc, struct, strategy, options) => { + const base = bonding.compute_bonds(structure, `electroneg_ratio`, {}) + const next = bonding.compute_bonds(struct, strategy, options) + expect(next).not.toBe(base) + }) + + test(`caches per-structure so interleaved distinct structures don't thrash`, () => { + // Two Structure components on one page compute bonds for different structures in the same + // flush. A single global memo slot would evict each other every call; the per-structure + // WeakMap keeps both warm so repeat calls still hit the cache. + const a1 = bonding.compute_bonds(structure, `electroneg_ratio`, {}) + const b1 = bonding.compute_bonds(other_structure, `electroneg_ratio`, {}) + expect(bonding.compute_bonds(structure, `electroneg_ratio`, {})).toBe(a1) + expect(bonding.compute_bonds(other_structure, `electroneg_ratio`, {})).toBe(b1) + }) + + test(`alternating strategies/options on one structure reuse results (no slot thrash)`, () => { + // A single { sig, bonds } slot per structure would evict the prior result on every + // strategy/options switch; the per-signature map keeps each warm so switching back hits cache. + const eneg = bonding.compute_bonds(structure, `electroneg_ratio`, {}) + const solid = bonding.compute_bonds(structure, `solid_angle`, {}) + expect(bonding.compute_bonds(structure, `electroneg_ratio`, {})).toBe(eneg) + expect(bonding.compute_bonds(structure, `solid_angle`, {})).toBe(solid) + }) +}) diff --git a/tests/vitest/structure/index.test.ts b/tests/vitest/structure/index.test.ts index ddc5e1999..04a62bc6c 100644 --- a/tests/vitest/structure/index.test.ts +++ b/tests/vitest/structure/index.test.ts @@ -1,6 +1,7 @@ import type { AnyStructure, ElementSymbol, Site, Species, Vec3 } from '$lib' import * as struct_utils from '$lib/structure' import { + DEFAULT_STRUCTURE_VIEWS, default_vector_configs, get_all_site_vectors, get_structure_vector_keys, @@ -469,6 +470,32 @@ describe(`default_vector_configs`, () => { }) }) +describe(`DEFAULT_STRUCTURE_VIEWS`, () => { + test(`matches the exact Ovito-like default set (label, projection, direction)`, () => { + expect(DEFAULT_STRUCTURE_VIEWS).toEqual([ + { label: `Perspective`, projection: `perspective`, direction: [1, 0.3, 0.8] }, + { label: `Front`, projection: `orthographic`, direction: [0, 0, 1] }, + { label: `Top`, projection: `orthographic`, direction: [0, 1, 0] }, + { label: `Right`, projection: `orthographic`, direction: [1, 0, 0] }, + ]) + }) + + test(`has exactly one perspective + three orthographic views for a 2x2 grid`, () => { + expect(DEFAULT_STRUCTURE_VIEWS).toHaveLength(4) + const projections = DEFAULT_STRUCTURE_VIEWS.map((view) => view.projection) + expect(projections.filter((proj) => proj === `perspective`)).toHaveLength(1) + expect(projections.filter((proj) => proj === `orthographic`)).toHaveLength(3) + }) + + test(`every view has a unique label and a non-zero direction`, () => { + const labels = DEFAULT_STRUCTURE_VIEWS.map((view) => view.label) + expect(new Set(labels).size).toBe(labels.length) + for (const view of DEFAULT_STRUCTURE_VIEWS) { + expect(Math.hypot(...(view.direction ?? [0, 0, 0]))).toBeGreaterThan(0) + } + }) +}) + // glob_text unwraps the module-namespace shape the Rolldown prod build returns // (vitest runs the dev transform, so this is the only place that path is tested) const parsed = { lattice: { a: 5 }, sites: [] } diff --git a/tests/vitest/table/HeatmapTable.test.svelte.ts b/tests/vitest/table/HeatmapTable.test.svelte.ts index a21646778..d095f5094 100644 --- a/tests/vitest/table/HeatmapTable.test.svelte.ts +++ b/tests/vitest/table/HeatmapTable.test.svelte.ts @@ -1,6 +1,7 @@ import { HeatmapTable, type Label, type RowData } from '$lib' import { type ComponentProps, mount, tick } from 'svelte' import { assert, describe, expect, it, vi } from 'vitest' +import { bind_props } from '../setup' describe(`HeatmapTable`, () => { const sample_data = [ @@ -69,18 +70,18 @@ describe(`HeatmapTable`, () => { }) it(`handles empty data and filters undefined rows`, async () => { - let data_with_empty = $state([{ Model: undefined, Score: undefined }, ...sample_data]) + const state = $state({ data: [{ Model: undefined, Score: undefined }, ...sample_data] }) mount(HeatmapTable, { target: document.body, - props: { data: data_with_empty, columns: sample_columns }, + props: bind_props({ columns: sample_columns }, state), }) expect(document.querySelectorAll(`tbody tr`)).toHaveLength(3) - data_with_empty = [] + state.data = [] await tick() - expect(document.querySelectorAll(`tbody tr`)).toHaveLength(3) + expect(document.querySelectorAll(`tbody tr`)).toHaveLength(1) }) describe(`Sorting and Data Updates`, () => { @@ -116,23 +117,23 @@ describe(`HeatmapTable`, () => { }) it(`maintains sort state on data updates`, async () => { - let data = $state(sample_data) + const state = $state({ data: sample_data }) mount(HeatmapTable, { target: document.body, - props: { data, columns: sample_columns }, + props: bind_props({ columns: sample_columns }, state), }) const score_header = document.querySelectorAll(`th`)[1] score_header.click() // Sort by Score await tick() - data = [{ Model: `D`, Score: 0.65, Value: 400 }, ...sample_data] + state.data = [{ Model: `D`, Score: 0.65, Value: 400 }, ...sample_data] await tick() const scores = Array.from(document.querySelectorAll(`td[data-col="Score"]`)).map( (cell) => cell.textContent?.trim(), ) - expect(scores).toEqual([`0.95`, `0.85`, `0.75`]) + expect(scores).toEqual([`0.95`, `0.85`, `0.75`, `0.65`]) }) it(`sorts date columns correctly`, () => { diff --git a/tests/vitest/trajectory/plotting.test.ts b/tests/vitest/trajectory/plotting.test.ts index 24d237fce..dbeedb017 100644 --- a/tests/vitest/trajectory/plotting.test.ts +++ b/tests/vitest/trajectory/plotting.test.ts @@ -106,6 +106,46 @@ describe(`generate_plot_series`, () => { assert_unit_group_constraints(series) }) + it(`memoizes extraction until extractor, trajectory, or frame identities change`, () => { + const trajectory = create_trajectory([{ energy: -10 }, { energy: -11 }]) + let call_count = 0 + const counting_extractor = (frame: TrajectoryFrame) => { + call_count++ + return test_extractor(frame) + } + + let series = generate_plot_series(trajectory, counting_extractor) + expect(find_series_by_label(series, `energy`)?.y).toEqual([-10, -11]) + expect(call_count).toBe(trajectory.frames.length) + + generate_plot_series(trajectory, counting_extractor) + expect(call_count).toBe(trajectory.frames.length) + + trajectory.frames.push(make_trajectory_frame(2, 1, { energy: -12 })) + series = generate_plot_series(trajectory, counting_extractor) + expect(find_series_by_label(series, `energy`)?.y).toEqual([-10, -11, -12]) + expect(call_count).toBe(5) + + trajectory.frames[1] = make_trajectory_frame(1, 1, { energy: -20 }) + series = generate_plot_series(trajectory, counting_extractor) + expect(find_series_by_label(series, `energy`)?.y).toEqual([-10, -20, -12]) + expect(call_count).toBe(8) + + const other_extractor = (frame: TrajectoryFrame) => { + call_count++ + return test_extractor(frame) + } + generate_plot_series(trajectory, other_extractor) + expect(call_count).toBe(11) + + const before_other_trajectory = call_count + generate_plot_series( + create_trajectory(COMMON_TRAJECTORIES.lattice_params), + counting_extractor, + ) + expect(call_count).toBeGreaterThan(before_other_trajectory) + }) + it.each([ { name: `empty trajectory`, frames: [], expected_length: 0 }, { name: `single frame`, frames: [{ energy: -10.0 }], expected_length: 0 }, @@ -263,13 +303,9 @@ describe(`should_hide_plot`, () => { expect(should_hide_plot(trajectory, series)).toBe(expected) }) - it(`should handle single frame trajectory and custom tolerance`, () => { - const single_frame = create_trajectory([{}]) - expect(should_hide_plot(single_frame, [])).toBe(true) // Single frame should hide plot - - const series = [create_series([1.0, 1.000001, 1.0])] - expect(should_hide_plot(trajectory, series)).toBe(false) // Default tolerance - expect(should_hide_plot(trajectory, series, 1e-2)).toBe(true) // Loose tolerance + it(`hides plot for single-frame trajectory despite a varying series`, () => { + const single_frame = create_trajectory([{ energy: -10 }]) + expect(should_hide_plot(single_frame, [create_series([1.0, 2.0, 3.0])])).toBe(true) }) it.each([ @@ -285,7 +321,8 @@ describe(`should_hide_plot`, () => { { name: `very strict`, tolerance: 1e-10, expected: false }, { name: `very loose`, tolerance: 1e10, expected: true }, { name: `zero tolerance`, tolerance: 0, expected: false }, - ])(`should handle extreme tolerance: $name`, ({ tolerance, expected }) => { + { name: `default (undefined) tolerance`, tolerance: undefined, expected: false }, + ])(`should handle tolerance: $name`, ({ tolerance, expected }) => { const series = [create_series([1.0, 1.0000001, 1.0])] expect(should_hide_plot(trajectory, series, tolerance)).toBe(expected) }) @@ -317,32 +354,27 @@ describe(`generate_axis_labels`, () => { series: [create_series([1, 2], false, `Hidden`, `eV`)], expected: { y1: `Value`, y2: `Value` }, }, + { + name: `series split across y1 and y2`, + series: [ + create_series([1, 2], true, `Energy`, `eV`, `y1`), + create_series([3, 4], true, `Force`, `eV/Å`, `y2`), + ], + expected: { y1: `Energy (eV)`, y2: `Force (eV/Å)` }, + }, + { + name: `multiple series concatenated on y1 with separate y2`, + series: [ + create_series([5.0, 5.1], true, `A`, `Å`, `y1`), + create_series([5.1, 5.2], true, `B`, `Å`, `y1`), + create_series([1.0, 2.0], true, `Energy`, `eV`, `y2`), + ], + expected: { y1: `A / B (Å)`, y2: `Energy (eV)` }, + }, ])(`should generate axis labels for $name`, ({ series, expected }) => { const labels = generate_axis_labels(series) expect(labels).toEqual(expected) }) - - it(`should handle different axes assignments`, () => { - const series = [ - { ...create_series([1, 2], true, `Energy`, `eV`), y_axis: `y1` as const }, - { ...create_series([3, 4], true, `Force`, `eV/Å`), y_axis: `y2` as const }, - ] - const labels = generate_axis_labels(series) - expect(labels.y1).toBe(`Energy (eV)`) - expect(labels.y2).toBe(`Force (eV/Å)`) - }) - - it(`should concatenate multiple series on same axis`, () => { - const series = [ - { ...create_series([5.0, 5.1], true, `A`, `Å`), y_axis: `y1` as const }, - { ...create_series([5.1, 5.2], true, `B`, `Å`), y_axis: `y1` as const }, - { ...create_series([1.0, 2.0], true, `Energy`, `eV`), y_axis: `y2` as const }, - ] - - const labels = generate_axis_labels(series) - expect(labels.y1).toBe(`A / B (Å)`) - expect(labels.y2).toBe(`Energy (eV)`) - }) }) describe(`integration and regression tests`, () => { diff --git a/tests/vitest/xrd/parse.test.ts b/tests/vitest/xrd/parse.test.ts index 5595f4029..2bf18268b 100644 --- a/tests/vitest/xrd/parse.test.ts +++ b/tests/vitest/xrd/parse.test.ts @@ -849,8 +849,8 @@ describe(`real example files`, () => { // Use ArrayBuffer for binary formats, string for text const is_binary = [`brml`, `raw`].includes(base_ext ?? ``) - const input = is_binary ? content.buffer : content.toString() - const result = await parse_xrd_file(input as string | ArrayBuffer, base_filename) + const input = is_binary ? new Uint8Array(content).buffer : content.toString() + const result = await parse_xrd_file(input, base_filename) expect(result).not.toBeNull() if (!result) return // Type guard for TypeScript