From aed665447d60a8c17ee92ecd5588df0edc070b52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 00:32:48 +0000 Subject: [PATCH] refactor: extract GraphRenderer and TableRenderer from god objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConfigurableGraph (805 → ~440 lines): - New GraphRenderer.ts owns all bamboo chart/grid/button rendering nodes, the clipped data container, axis labels, and axis interaction regions. - ConfigurableGraph is now a thin coordinator: creates ChartTransform, instantiates sub-modules, wires listeners, and delegates rendering and resize updates to GraphRenderer. - Extracted mapDataPoints() as a module-level function so setTrackData() no longer mixes data-mapping logic with plot management. DataTableNode (839 → ~290 lines): - New TableRenderer.ts owns all HTML DOM construction and incremental-update state (lastTrackIds, lastUnit, tableBodyRef, frameRowMap, maxRenderedFrame). - TableRenderer.update() encapsulates the structural-vs-incremental decision previously nested deep inside the DataTableNode constructor. - DataTableNode now focuses solely on SceneryStack integration: Panel, drag listener, resize handles, and reactive property wiring. https://claude.ai/code/session_01Ns5vMGXj1wSUU3pwzTfYco --- src/screen-name/graph/ConfigurableGraph.ts | 591 ++++----------------- src/screen-name/graph/GraphRenderer.ts | 288 ++++++++++ src/screen-name/view/DataTableNode.ts | 565 +++----------------- src/screen-name/view/TableRenderer.ts | 410 ++++++++++++++ 4 files changed, 883 insertions(+), 971 deletions(-) create mode 100644 src/screen-name/graph/GraphRenderer.ts create mode 100644 src/screen-name/view/TableRenderer.ts diff --git a/src/screen-name/graph/ConfigurableGraph.ts b/src/screen-name/graph/ConfigurableGraph.ts index 6613559..8b8e5ec 100644 --- a/src/screen-name/graph/ConfigurableGraph.ts +++ b/src/screen-name/graph/ConfigurableGraph.ts @@ -3,11 +3,12 @@ * * ## Subsystem overview * - * The graph subsystem lives in `src/screen-name/graph/` and consists of five files: + * The graph subsystem lives in `src/screen-name/graph/` and consists of seven files: * * | File | Role | * |------|------| - * | `ConfigurableGraph.ts` | Top-level SceneryStack node; owns the bamboo chart, axis labels, control buttons, and coordinates all sub-modules | + * | `ConfigurableGraph.ts` | Top-level SceneryStack node; thin coordinator — wires sub-modules together | + * | `GraphRenderer.ts` | Creates and owns all rendering nodes (chart, grid, buttons, axis labels) | * | `GraphDataManager.ts` | Accumulates (x, y) data points, drives auto-scaling, and owns the tick-spacing algorithm | * | `GraphInteractionHandler.ts` | All pointer/touch/keyboard gestures: pan, pinch-zoom, axis-drag, header-drag, corner-resize | * | `GraphControlsPanel.ts` | Builds the axis-selector dropdowns and header bar UI | @@ -18,7 +19,8 @@ * * ``` * KinematicsGraphNode (view layer — owns track selection and wires model → graph) - * └─ ConfigurableGraph (graph node — layout, bamboo chart, buttons) + * └─ ConfigurableGraph (graph node — thin coordinator) + * ├─ GraphRenderer (chart background, grid, tick labels, axis labels, buttons) * ├─ GraphDataManager (data store, auto-scale, tick math) * ├─ GraphInteractionHandler * │ ├─ ZoomGestureHandler @@ -51,92 +53,72 @@ * show the graph; the ToolsControlPanel checkbox is the standard toggle. */ -import { BooleanProperty, Property, type TReadOnlyProperty } from "scenerystack/axon"; -import { ChartRectangle, ChartTransform, GridLineSet, LinePlot, TickLabelSet, TickMarkSet } from "scenerystack/bamboo"; +import { BooleanProperty, Property } from "scenerystack/axon"; +import { ChartTransform, LinePlot } from "scenerystack/bamboo"; import { Range } from "scenerystack/dot"; -import { Shape } from "scenerystack/kite"; -import { Orientation } from "scenerystack/phet-core"; -import { FireListener, HBox, Node, Rectangle, Text } from "scenerystack/scenery"; -import { PhetFont } from "scenerystack/scenery-phet"; +import { Node } from "scenerystack/scenery"; import { StringManager } from "../../i18n/StringManager.js"; -import TrackLabColors from "../../TrackLabColors.js"; import trackLab from "../../TrackLabNamespace.js"; import GraphControlsPanel from "./GraphControlsPanel.js"; import GraphDataManager from "./GraphDataManager.js"; import GraphInteractionHandler from "./GraphInteractionHandler.js"; +import GraphRenderer from "./GraphRenderer.js"; import type { PlottableProperty } from "./PlottableProperty.js"; -// Grid line styling -const GRID_LINE_WIDTH = 0.5; +// Line-plot stroke width — only ConfigurableGraph creates LinePlots const PLOT_LINE_WIDTH = 2; -const TICK_EXTENT = 8; -const TICK_LABEL_FONT = new PhetFont({ size: 10 }); -const TICK_LABEL_DECIMALS = 2; - -// Axis labels -const AXIS_LABEL_FONT = new PhetFont({ size: 12 }); -const AXIS_LABEL_OFFSET = 35; - -// Axis interaction regions -const Y_AXIS_INTERACTION_WIDTH = 60; -const X_AXIS_INTERACTION_HEIGHT = 30; - -// Control button styling -const BUTTON_SIZE = 24; -const BUTTON_PADDING = 4; -const BUTTON_SPACING = 2; -const BUTTON_CORNER_RADIUS = 3; -const BUTTON_FONT = new PhetFont({ size: 14, weight: "bold" }); -const BUTTON_HOVER_OPACITY = 0.8; const TITLE_BOTTOM_OFFSET = -5; +/** + * Map raw data-point records to {x, y} pairs using the current axis accessors. + * NaN points are filtered out so the line plot stays clean. + */ +function mapDataPoints( + dataPoints: Array>, + xProperty: PlottableProperty, + yProperty: PlottableProperty, +): Array<{ x: number; y: number }> { + const result: Array<{ x: number; y: number }> = []; + for (const point of dataPoints) { + const x = "accessor" in xProperty ? xProperty.accessor(point) : 0; + const y = "accessor" in yProperty ? yProperty.accessor(point) : 0; + if (!(Number.isNaN(x) || Number.isNaN(y))) { + result.push({ x, y }); + } + } + return result; +} + export default class ConfigurableGraph extends Node { private readonly xPropertyProperty: Property; private readonly yPropertyProperty: Property; - private readonly chartTransform: ChartTransform; + private readonly graphVisibleProperty: BooleanProperty; + private readonly isDraggingProperty: BooleanProperty; + private readonly isResizingProperty: BooleanProperty; + private graphWidth: number; private graphHeight: number; private readonly initialWidth: number; private readonly initialHeight: number; - - // Drag and resize UI components - private readonly headerBar; - - // Clipped data container for line plot and trail - private readonly clippedDataContainer: Node; - - // Multi-track support: map of trackId -> {linePlot, dataManager} - private readonly trackPlots: Map = new Map(); private readonly maxDataPoints: number; - // Visibility control - private readonly graphVisibleProperty: BooleanProperty; - private readonly graphContentNode: Node; - - // Axis labels - private readonly xAxisLabelNode: Text; - private readonly yAxisLabelNode: Text; - - // Invisible interaction regions for axis controls - private readonly xAxisInteractionRegion: Rectangle; - private readonly yAxisInteractionRegion: Rectangle; + // The shared chart transform (owned here, passed to renderer and data managers) + private readonly chartTransform: ChartTransform; - // Module instances + // Sub-modules + private readonly graphRenderer: GraphRenderer; private readonly dataManager: GraphDataManager; private readonly interactionHandler: GraphInteractionHandler; private readonly controlsPanel: GraphControlsPanel; - // Grid and tick components (shared across all tracks) - private readonly gridConfig: import("./GraphDataManager.js").GridVisualizationConfig; - - // Title panel with combo boxes (needs to be on top of header bar) + // UI nodes that ConfigurableGraph directly manages + private readonly headerBar; private readonly titlePanel: Node; - // Drag and resize state properties (kept for dispose) - private readonly isDraggingProperty: BooleanProperty; - private readonly isResizingProperty: BooleanProperty; + // Multi-track support: map of trackId -> {linePlot, dataManager} + private readonly trackPlots: Map = new Map(); - // Listener refs kept for unlink in dispose() + // Listener refs for dispose private readonly disposeConfigurableGraph: () => void; /** @@ -167,23 +149,13 @@ export default class ConfigurableGraph extends Node { this.initialHeight = height; this.maxDataPoints = maxDataPoints; - // Properties to track current axis selections this.xPropertyProperty = new Property(initialXProperty); this.yPropertyProperty = new Property(initialYProperty); - - // Property to control graph visibility this.graphVisibleProperty = new BooleanProperty(false); - - // Properties for drag and resize states this.isDraggingProperty = new BooleanProperty(false); this.isResizingProperty = new BooleanProperty(false); - const isDraggingProperty = this.isDraggingProperty; - const isResizingProperty = this.isResizingProperty; - // Create a container for all graph content - this.graphContentNode = new Node(); - - // Create chart transform with initial ranges + // ── ChartTransform (shared across renderer and all data managers) ───────── const initialRange = new Range(-10, 10); this.chartTransform = new ChartTransform({ viewWidth: width, @@ -192,314 +164,67 @@ export default class ConfigurableGraph extends Node { modelYRange: initialRange, }); - // Create chart background - const chartRectangle = new ChartRectangle(this.chartTransform, { - fill: TrackLabColors.graphBackgroundProperty, - stroke: TrackLabColors.controlPanelStrokeProperty, - }); - this.graphContentNode.addChild(chartRectangle); - - // Create grid lines, tick marks, and tick labels - const initialSpacing = GraphDataManager.calculateTickSpacing(initialRange.getLength()); - - const verticalGridLineSet = new GridLineSet(this.chartTransform, Orientation.VERTICAL, initialSpacing, { - stroke: TrackLabColors.gridLinesProperty, - lineWidth: GRID_LINE_WIDTH, - }); - this.graphContentNode.addChild(verticalGridLineSet); - - const horizontalGridLineSet = new GridLineSet(this.chartTransform, Orientation.HORIZONTAL, initialSpacing, { - stroke: TrackLabColors.gridLinesProperty, - lineWidth: GRID_LINE_WIDTH, - }); - this.graphContentNode.addChild(horizontalGridLineSet); - - const xTickMarkSet = new TickMarkSet(this.chartTransform, Orientation.HORIZONTAL, initialSpacing, { - edge: "min", - extent: TICK_EXTENT, - stroke: TrackLabColors.controlPanelStrokeProperty, - }); - this.graphContentNode.addChild(xTickMarkSet); - - const yTickMarkSet = new TickMarkSet(this.chartTransform, Orientation.VERTICAL, initialSpacing, { - edge: "min", - extent: TICK_EXTENT, - stroke: TrackLabColors.controlPanelStrokeProperty, - }); - this.graphContentNode.addChild(yTickMarkSet); - - const xTickLabelSet = new TickLabelSet(this.chartTransform, Orientation.HORIZONTAL, initialSpacing, { - edge: "min", - createLabel: (value: number) => - new Text(value.toFixed(TICK_LABEL_DECIMALS), { - font: TICK_LABEL_FONT, - fill: TrackLabColors.textProperty, - }), - }); - this.graphContentNode.addChild(xTickLabelSet); - - const yTickLabelSet = new TickLabelSet(this.chartTransform, Orientation.VERTICAL, initialSpacing, { - edge: "min", - createLabel: (value: number) => - new Text(value.toFixed(TICK_LABEL_DECIMALS), { - font: TICK_LABEL_FONT, - fill: TrackLabColors.textProperty, - }), - }); - this.graphContentNode.addChild(yTickLabelSet); - - // Create invisible interaction regions for axis controls - // These regions capture mouse/touch events across the entire tick label area, - // not just on the text labels themselves - const axisInteractionWidth = Y_AXIS_INTERACTION_WIDTH; - const axisInteractionHeight = X_AXIS_INTERACTION_HEIGHT; - - // Y-axis interaction region (left side of graph, covering full height) - this.yAxisInteractionRegion = new Rectangle(-axisInteractionWidth, 0, axisInteractionWidth, height, { - fill: "transparent", - pickable: true, - }); - this.graphContentNode.addChild(this.yAxisInteractionRegion); - - // X-axis interaction region (bottom of graph, covering full width) - this.xAxisInteractionRegion = new Rectangle(0, height, width, axisInteractionHeight, { - fill: "transparent", - pickable: true, - }); - this.graphContentNode.addChild(this.xAxisInteractionRegion); - - // Clipped container for all track line plots; prevents overflow beyond the grid. - this.clippedDataContainer = new Node({ - clipArea: Shape.rect(0, 0, width, height), - }); - this.graphContentNode.addChild(this.clippedDataContainer); - - // Create axis labels - this.xAxisLabelNode = new Text(this.formatAxisLabel(initialXProperty), { - font: AXIS_LABEL_FONT, - fill: TrackLabColors.textProperty, - centerX: this.graphWidth / 2, - top: this.graphHeight + AXIS_LABEL_OFFSET, - }); - this.graphContentNode.addChild(this.xAxisLabelNode); - - this.yAxisLabelNode = new Text(this.formatAxisLabel(initialYProperty), { - font: AXIS_LABEL_FONT, - fill: TrackLabColors.textProperty, - rotation: -Math.PI / 2, - centerY: this.graphHeight / 2, - right: -AXIS_LABEL_OFFSET, + // ── Renderer (chart background, grid, tick labels, axis labels, buttons) ── + // Button callbacks use arrow functions so interactionHandler / dataManager + // references resolve lazily after they are assigned below. + this.graphRenderer = new GraphRenderer(this.chartTransform, width, height, initialXProperty, initialYProperty, { + onRescale: () => { + this.dataManager.setManuallyZoomed(false); + this.dataManager.updateAxisRanges(); + }, + onZoomIn: () => this.interactionHandler.zoomIn(), + onZoomOut: () => this.interactionHandler.zoomOut(), + onPan: (dir) => this.interactionHandler.pan(dir), }); - this.graphContentNode.addChild(this.yAxisLabelNode); - - // Store grid config for creating track-specific data managers - this.gridConfig = { - verticalGridLineSet, - horizontalGridLineSet, - xTickMarkSet, - yTickMarkSet, - xTickLabelSet, - yTickLabelSet, - }; - // Coordinator for tick spacing, axis reset, and zoom-flag state shared by - // gesture handlers. No LinePlot: track plots are managed via setTrackData(). - this.dataManager = new GraphDataManager(this.chartTransform, null, maxDataPoints, this.gridConfig); + // ── DataManager (coordinator — no LinePlot of its own) ─────────────────── + this.dataManager = new GraphDataManager(this.chartTransform, null, maxDataPoints, this.graphRenderer.gridConfig); - // Create controls panel helper + // ── Controls panel ─────────────────────────────────────────────────────── this.controlsPanel = new GraphControlsPanel( availableProperties, this.xPropertyProperty, this.yPropertyProperty, this.graphWidth, ); - const controlsPanel = this.controlsPanel; - // Create title panel with combo boxes for axis selection - // Note: titlePanel is added to 'this' (not graphContentNode) after headerBar - // so combo boxes remain accessible while header bar can be dragged - this.titlePanel = controlsPanel.createTitlePanel(listParent); + this.titlePanel = this.controlsPanel.createTitlePanel(listParent); this.titlePanel.centerX = this.graphWidth / 2; this.titlePanel.bottom = TITLE_BOTTOM_OFFSET; - // Create control buttons panel with rescale, zoom, and pan buttons - const buttonSize = BUTTON_SIZE; - const buttonPadding = BUTTON_PADDING; - const buttonSpacing = BUTTON_SPACING; - const a11yStrings = StringManager.getInstance().getA11y(); + this.headerBar = this.controlsPanel.createHeaderBar(a11yStrings.graphPanelHeaderStringProperty); - // Helper function to create a button - const createButton = (label: string, onClick: () => void, accessibleName?: TReadOnlyProperty): Node => { - const buttonText = new Text(label, { - font: BUTTON_FONT, - fill: TrackLabColors.controlPanelStrokeProperty, - }); - - const buttonBackground = new Rectangle(0, 0, buttonSize, buttonSize, BUTTON_CORNER_RADIUS, BUTTON_CORNER_RADIUS, { - fill: TrackLabColors.controlPanelFillProperty, - stroke: TrackLabColors.controlPanelStrokeProperty, - cursor: "pointer", - }); - - const button = new Node({ - children: [buttonBackground, buttonText], - tagName: "button", - ...(accessibleName && { accessibleName }), - }); - - // Center the text in the button - buttonText.center = buttonBackground.center; - - // Add hover effect - button.addInputListener({ - enter: () => { - buttonBackground.opacity = BUTTON_HOVER_OPACITY; - }, - exit: () => { - buttonBackground.opacity = 1.0; - }, - }); - - // Add click handler - button.addInputListener( - new FireListener({ - fire: onClick, - }), - ); - - return button; - }; - - // Create rescale button - const rescaleButton = createButton( - "↻", - () => { - // Reset manual zoom flag and rescale to fit data - this.dataManager.setManuallyZoomed(false); - this.dataManager.updateAxisRanges(); - }, - a11yStrings.graphRescaleStringProperty, - ); - - // Create zoom buttons - const zoomInButton = createButton( - "+", - () => { - this.interactionHandler.zoomIn(); - }, - a11yStrings.graphZoomInStringProperty, - ); - - const zoomOutButton = createButton( - "−", - () => { - this.interactionHandler.zoomOut(); - }, - a11yStrings.graphZoomOutStringProperty, - ); - - // Create pan buttons - const panLeftButton = createButton( - "←", - () => { - this.interactionHandler.pan("left"); - }, - a11yStrings.graphPanLeftStringProperty, - ); - - const panRightButton = createButton( - "→", - () => { - this.interactionHandler.pan("right"); - }, - a11yStrings.graphPanRightStringProperty, - ); - - const panUpButton = createButton( - "↑", - () => { - this.interactionHandler.pan("up"); - }, - a11yStrings.graphPanUpStringProperty, - ); - - const panDownButton = createButton( - "↓", - () => { - this.interactionHandler.pan("down"); - }, - a11yStrings.graphPanDownStringProperty, - ); - - // Create HBox to hold all buttons - const controlButtonsPanel = new HBox({ - children: [rescaleButton, zoomInButton, zoomOutButton, panLeftButton, panRightButton, panUpButton, panDownButton], - spacing: buttonSpacing, - left: buttonPadding, - top: buttonPadding, - }); - - this.graphContentNode.addChild(controlButtonsPanel); - - // Update labels when axes change - const xPropertyListener = (property: PlottableProperty) => { - this.xAxisLabelNode.string = this.formatAxisLabel(property); - this.xAxisLabelNode.centerX = this.graphWidth / 2; - this.clearData(); - }; - this.xPropertyProperty.link(xPropertyListener); - - const yPropertyListener = (property: PlottableProperty) => { - this.yAxisLabelNode.string = this.formatAxisLabel(property); - this.yAxisLabelNode.centerY = this.graphHeight / 2; - this.clearData(); - }; - this.yPropertyProperty.link(yPropertyListener); - - // Create header bar (checkbox is now in ToolsControlPanel) - this.headerBar = controlsPanel.createHeaderBar(a11yStrings.graphPanelHeaderStringProperty); - - // Add the graph content container first - this.addChild(this.graphContentNode); - - // Add header bar after graphContentNode so it's on top and can receive drag events + // ── Scene graph assembly ───────────────────────────────────────────────── + this.addChild(this.graphRenderer.contentNode); this.addChild(this.headerBar); - - // Add title panel after header bar so combo boxes remain accessible this.addChild(this.titlePanel); - // Initialize interaction handler + // ── Interaction handler ────────────────────────────────────────────────── this.interactionHandler = new GraphInteractionHandler( { chartTransform: this.chartTransform, - chartRectangle, + chartRectangle: this.graphRenderer.chartRectangle, dataManager: this.dataManager, }, { - isDraggingProperty, - isResizingProperty, + isDraggingProperty: this.isDraggingProperty, + isResizingProperty: this.isResizingProperty, }, { headerBar: this.headerBar, graphNode: this, ...(dragTargetNode && { dragTargetNode }), - xTickLabelSet, - yTickLabelSet, - xAxisInteractionRegion: this.xAxisInteractionRegion, - yAxisInteractionRegion: this.yAxisInteractionRegion, - }, - { - width: this.graphWidth, - height: this.graphHeight, + xTickLabelSet: this.graphRenderer.gridConfig.xTickLabelSet, + yTickLabelSet: this.graphRenderer.gridConfig.yTickLabelSet, + xAxisInteractionRegion: this.graphRenderer.xAxisInteractionRegion, + yAxisInteractionRegion: this.graphRenderer.yAxisInteractionRegion, }, + { width: this.graphWidth, height: this.graphHeight }, this.resizeGraph.bind(this), ); - - // Setup all interactions this.interactionHandler.initialize(); - // Create and add resize handles const resizeHandles = this.interactionHandler.createResizeHandles([ a11yStrings.graphResizeTopLeftStringProperty, a11yStrings.graphResizeTopRightStringProperty, @@ -510,9 +235,21 @@ export default class ConfigurableGraph extends Node { this.addChild(handle); } - // Link visibility property to the content node, header bar, title panel, and resize handles + // ── Property listeners ─────────────────────────────────────────────────── + const xPropertyListener = (property: PlottableProperty) => { + this.graphRenderer.updateXAxisLabel(property); + this.clearData(); + }; + this.xPropertyProperty.link(xPropertyListener); + + const yPropertyListener = (property: PlottableProperty) => { + this.graphRenderer.updateYAxisLabel(property); + this.clearData(); + }; + this.yPropertyProperty.link(yPropertyListener); + const graphVisibleListener = (visible: boolean) => { - this.graphContentNode.visible = visible; + this.graphRenderer.contentNode.visible = visible; this.headerBar.visible = visible; this.titlePanel.visible = visible; for (const handle of resizeHandles) { @@ -521,24 +258,23 @@ export default class ConfigurableGraph extends Node { }; this.graphVisibleProperty.link(graphVisibleListener); - // Add visual feedback for drag and resize operations const isDraggingListener = (isDragging: boolean) => { this.opacity = isDragging ? 0.8 : 1.0; this.headerBar.cursor = isDragging ? "grabbing" : "grab"; }; - isDraggingProperty.link(isDraggingListener); + this.isDraggingProperty.link(isDraggingListener); const isResizingListener = (isResizing: boolean) => { this.opacity = isResizing ? 0.8 : 1.0; }; - isResizingProperty.link(isResizingListener); + this.isResizingProperty.link(isResizingListener); this.disposeConfigurableGraph = () => { this.xPropertyProperty.unlink(xPropertyListener); this.yPropertyProperty.unlink(yPropertyListener); this.graphVisibleProperty.unlink(graphVisibleListener); - isDraggingProperty.unlink(isDraggingListener); - isResizingProperty.unlink(isResizingListener); + this.isDraggingProperty.unlink(isDraggingListener); + this.isResizingProperty.unlink(isResizingListener); this.controlsPanel.dispose(); this.xPropertyProperty.dispose(); this.yPropertyProperty.dispose(); @@ -548,159 +284,76 @@ export default class ConfigurableGraph extends Node { }; } - /** - * Helper to get the string value from either a string or TReadOnlyProperty - */ - private getNameValue(name: string | TReadOnlyProperty): string { - return typeof name === "string" ? name : name.value; - } - - /** - * Get the string value from a unit (which can be string or TReadOnlyProperty) - */ - private getUnitValue(unit: string | TReadOnlyProperty | undefined): string | undefined { - if (unit === undefined) { - return undefined; - } - return typeof unit === "string" ? unit : unit.value; - } - - /** - * Format an axis label with the property name and unit - */ - private formatAxisLabel(property: PlottableProperty): string { - const nameValue = this.getNameValue(property.name); - const unitValue = this.getUnitValue(property.unit); - if (unitValue) { - return `${nameValue} (${unitValue})`; - } - return nameValue; - } + // ── Resize ────────────────────────────────────────────────────────────────── - /** - * Resize the graph to new dimensions - */ private resizeGraph(newWidth: number, newHeight: number): void { this.graphWidth = newWidth; this.graphHeight = newHeight; - // Update header bar GraphControlsPanel.updateHeaderBarWidth(this.headerBar, newWidth); + this.graphRenderer.updateDimensions(newWidth, newHeight); - // Update clipping area BEFORE updating chart transform to prevent temporary clipping during resize - this.clippedDataContainer.clipArea = Shape.rect(0, 0, newWidth, newHeight); - - // Update chart transform this.chartTransform.setViewWidth(newWidth); this.chartTransform.setViewHeight(newHeight); - // Update invisible interaction regions - this.yAxisInteractionRegion.setRect(-Y_AXIS_INTERACTION_WIDTH, 0, Y_AXIS_INTERACTION_WIDTH, newHeight); - this.xAxisInteractionRegion.setRect(0, newHeight, newWidth, X_AXIS_INTERACTION_HEIGHT); - - // Update axis labels positions - this.xAxisLabelNode.centerX = newWidth / 2; - this.xAxisLabelNode.top = newHeight + AXIS_LABEL_OFFSET; - this.yAxisLabelNode.centerY = newHeight / 2; - - // Update title panel position this.titlePanel.centerX = newWidth / 2; - - // Update interaction handler dimensions this.interactionHandler.updateDimensions(newWidth, newHeight); this.interactionHandler.updateResizeHandlePositions(); } - /** - * Clear all data points - */ + // ── Public API ────────────────────────────────────────────────────────────── + public clearData(): void { this.dataManager.clearData(); } - /** - * Update the axis labels (call when units change) - */ public updateAxisLabels(): void { - this.xAxisLabelNode.string = this.formatAxisLabel(this.xPropertyProperty.value); - this.yAxisLabelNode.string = this.formatAxisLabel(this.yPropertyProperty.value); + this.graphRenderer.updateXAxisLabel(this.xPropertyProperty.value); + this.graphRenderer.updateYAxisLabel(this.yPropertyProperty.value); } - /** - * Get the current x-axis property - */ public getXProperty(): PlottableProperty { return this.xPropertyProperty.value; } - /** - * Get the current y-axis property - */ public getYProperty(): PlottableProperty { return this.yPropertyProperty.value; } - /** - * Get the x-axis property Property (for listening to changes) - */ public getXPropertyProperty(): Property { return this.xPropertyProperty; } - /** - * Get the y-axis property Property (for listening to changes) - */ public getYPropertyProperty(): Property { return this.yPropertyProperty; } - /** - * Get the graph visibility property - */ public getGraphVisibleProperty(): BooleanProperty { return this.graphVisibleProperty; } - /** - * Get the current graph width - */ public getGraphWidth(): number { return this.graphWidth; } - /** - * Get the current graph height - */ public getGraphHeight(): number { return this.graphHeight; } - /** - * Update the set of quantities available in the axis-selector combo boxes. - * - * If the currently selected X or Y property is no longer in the new list it - * is reset to the first available property before the combo boxes are rebuilt. - * - * @param newProperties - The replacement list of plottable properties. - */ public setAvailableProperties(newProperties: PlottableProperty[]): void { if (newProperties.length === 0) { return; } - const first = newProperties[0]; if (!first) { return; } - - // Reset selections that are no longer available. if (!newProperties.includes(this.xPropertyProperty.value)) { this.xPropertyProperty.value = first; } if (!newProperties.includes(this.yPropertyProperty.value)) { this.yPropertyProperty.value = first; } - this.controlsPanel.rebuildComboBoxes(newProperties); } @@ -709,91 +362,57 @@ export default class ConfigurableGraph extends Node { super.dispose(); } - /** - * Reset the graph to its initial state - */ public reset(): void { - // Reset visibility property to initial value (false) this.graphVisibleProperty.reset(); - - // Reset graph size to initial dimensions if it has been resized if (this.graphWidth !== this.initialWidth || this.graphHeight !== this.initialHeight) { this.resizeGraph(this.initialWidth, this.initialHeight); } - - // Clear all data this.clearData(); this.clearAllTracks(); } - // ── Multi-track support ───────────────────────────────────────────────────── + // ── Multi-track support ────────────────────────────────────────────────────── - /** - * Add or update a track's plot with new data. - * @param trackId - Unique identifier for the track - * @param trackColor - Color for the track's line plot - * @param dataPoints - Array of data points for this track - */ public setTrackData( trackId: string, trackColor: import("scenerystack/scenery").TColor, dataPoints: Array>, ): void { - // Create a new plot if this track doesn't exist yet if (!this.trackPlots.has(trackId)) { const linePlot = new LinePlot(this.chartTransform, [], { stroke: trackColor, lineWidth: PLOT_LINE_WIDTH, }); - - // Create a data manager for this track using the shared grid config - const dataManager = new GraphDataManager(this.chartTransform, linePlot, this.maxDataPoints, this.gridConfig); - + const dataManager = new GraphDataManager( + this.chartTransform, + linePlot, + this.maxDataPoints, + this.graphRenderer.gridConfig, + ); this.trackPlots.set(trackId, { linePlot, dataManager }); - this.clippedDataContainer.addChild(linePlot); + this.graphRenderer.clippedDataContainer.addChild(linePlot); } - // Get the track's data manager const trackPlot = this.trackPlots.get(trackId); if (!trackPlot) { return; } - // Map the data points to x,y coordinates using the current axis properties - const xProperty = this.xPropertyProperty.value; - const yProperty = this.yPropertyProperty.value; - - const mappedPoints: Array<{ x: number; y: number }> = []; - for (const point of dataPoints) { - const x = "accessor" in xProperty ? xProperty.accessor(point) : 0; - const y = "accessor" in yProperty ? yProperty.accessor(point) : 0; - if (!(Number.isNaN(x) || Number.isNaN(y))) { - mappedPoints.push({ x, y }); - } - } - - // Clear and update this track's data + const mappedPoints = mapDataPoints(dataPoints, this.xPropertyProperty.value, this.yPropertyProperty.value); trackPlot.dataManager.clearData(); if (mappedPoints.length > 0) { trackPlot.dataManager.addDataPoints(mappedPoints); } } - /** - * Remove a track's plot from the graph. - * @param trackId - Unique identifier for the track to remove - */ public removeTrack(trackId: string): void { const trackPlot = this.trackPlots.get(trackId); if (trackPlot) { - this.clippedDataContainer.removeChild(trackPlot.linePlot); + this.graphRenderer.clippedDataContainer.removeChild(trackPlot.linePlot); this.trackPlots.delete(trackId); } } - /** - * Clear all track plots from the graph. - */ public clearAllTracks(): void { for (const [trackId] of this.trackPlots) { this.removeTrack(trackId); diff --git a/src/screen-name/graph/GraphRenderer.ts b/src/screen-name/graph/GraphRenderer.ts new file mode 100644 index 0000000..229bbfe --- /dev/null +++ b/src/screen-name/graph/GraphRenderer.ts @@ -0,0 +1,288 @@ +/** + * GraphRenderer — creates and owns all static rendering nodes for ConfigurableGraph. + * + * Extracted from ConfigurableGraph so that the coordinator class stays thin. + * Responsibilities: + * - Bamboo chart background, grid lines, tick marks, and tick labels + * - Axis interaction regions (invisible hit areas for axis gestures) + * - Clipped data container (clips LinePlot children to chart bounds) + * - Axis label Text nodes + * - Control buttons panel (rescale, zoom, pan) + * + * After construction, the caller adds `contentNode` to the scene graph and + * accesses individual sub-nodes via the public readonly properties. + */ + +import type { TReadOnlyProperty } from "scenerystack/axon"; +import type { ChartTransform } from "scenerystack/bamboo"; +import { ChartRectangle, GridLineSet, TickLabelSet, TickMarkSet } from "scenerystack/bamboo"; +import { Shape } from "scenerystack/kite"; +import { Orientation } from "scenerystack/phet-core"; +import { FireListener, HBox, Node, Rectangle, Text } from "scenerystack/scenery"; +import { PhetFont } from "scenerystack/scenery-phet"; +import { StringManager } from "../../i18n/StringManager.js"; +import TrackLabColors from "../../TrackLabColors.js"; +import trackLab from "../../TrackLabNamespace.js"; +import GraphDataManager, { type GridVisualizationConfig } from "./GraphDataManager.js"; +import type { PlottableProperty } from "./PlottableProperty.js"; + +// ── Styling constants (kept local — only GraphRenderer uses them) ───────────── +const GRID_LINE_WIDTH = 0.5; +const TICK_EXTENT = 8; +const TICK_LABEL_FONT = new PhetFont({ size: 10 }); +const TICK_LABEL_DECIMALS = 2; + +const AXIS_LABEL_FONT = new PhetFont({ size: 12 }); +export const AXIS_LABEL_OFFSET = 35; // exported so resizeGraph can use the same value + +const Y_AXIS_INTERACTION_WIDTH = 60; +const X_AXIS_INTERACTION_HEIGHT = 30; + +const BUTTON_SIZE = 24; +const BUTTON_PADDING = 4; +const BUTTON_SPACING = 2; +const BUTTON_CORNER_RADIUS = 3; +const BUTTON_FONT = new PhetFont({ size: 14, weight: "bold" }); +const BUTTON_HOVER_OPACITY = 0.8; + +/** Callbacks wired from ConfigurableGraph into the control buttons. */ +export type ButtonCallbacks = { + onRescale: () => void; + onZoomIn: () => void; + onZoomOut: () => void; + onPan: (direction: "left" | "right" | "up" | "down") => void; +}; + +/** Helper: format an axis label string from a PlottableProperty. */ +export function formatAxisLabel(property: PlottableProperty): string { + const name = typeof property.name === "string" ? property.name : property.name.value; + const unit = + property.unit === undefined ? undefined : typeof property.unit === "string" ? property.unit : property.unit.value; + return unit ? `${name} (${unit})` : name; +} + +export default class GraphRenderer { + /** Top-level container — add this to the scene graph. */ + public readonly contentNode: Node; + + // Bamboo chart background + public readonly chartRectangle: ChartRectangle; + + // Shared grid / tick config for GraphDataManager + public readonly gridConfig: GridVisualizationConfig; + + /** Clipped container for LinePlot children (prevents overflow). */ + public readonly clippedDataContainer: Node; + + /** Axis label nodes (text updates on axis-property change). */ + public readonly xAxisLabelNode: Text; + public readonly yAxisLabelNode: Text; + + /** Invisible hit regions for axis drag gestures. */ + public readonly xAxisInteractionRegion: Rectangle; + public readonly yAxisInteractionRegion: Rectangle; + + private graphWidth: number; + private graphHeight: number; + + public constructor( + chartTransform: ChartTransform, + width: number, + height: number, + initialXProperty: PlottableProperty, + initialYProperty: PlottableProperty, + callbacks: ButtonCallbacks, + ) { + this.graphWidth = width; + this.graphHeight = height; + + this.contentNode = new Node(); + + // ── Chart background ───────────────────────────────────────────────────── + this.chartRectangle = new ChartRectangle(chartTransform, { + fill: TrackLabColors.graphBackgroundProperty, + stroke: TrackLabColors.controlPanelStrokeProperty, + }); + this.contentNode.addChild(this.chartRectangle); + + // ── Grid lines, tick marks, tick labels ────────────────────────────────── + const initialSpacing = GraphDataManager.calculateTickSpacing(20); // initial range length = 20 + + const verticalGridLineSet = new GridLineSet(chartTransform, Orientation.VERTICAL, initialSpacing, { + stroke: TrackLabColors.gridLinesProperty, + lineWidth: GRID_LINE_WIDTH, + }); + this.contentNode.addChild(verticalGridLineSet); + + const horizontalGridLineSet = new GridLineSet(chartTransform, Orientation.HORIZONTAL, initialSpacing, { + stroke: TrackLabColors.gridLinesProperty, + lineWidth: GRID_LINE_WIDTH, + }); + this.contentNode.addChild(horizontalGridLineSet); + + const xTickMarkSet = new TickMarkSet(chartTransform, Orientation.HORIZONTAL, initialSpacing, { + edge: "min", + extent: TICK_EXTENT, + stroke: TrackLabColors.controlPanelStrokeProperty, + }); + this.contentNode.addChild(xTickMarkSet); + + const yTickMarkSet = new TickMarkSet(chartTransform, Orientation.VERTICAL, initialSpacing, { + edge: "min", + extent: TICK_EXTENT, + stroke: TrackLabColors.controlPanelStrokeProperty, + }); + this.contentNode.addChild(yTickMarkSet); + + const xTickLabelSet = new TickLabelSet(chartTransform, Orientation.HORIZONTAL, initialSpacing, { + edge: "min", + createLabel: (value: number) => + new Text(value.toFixed(TICK_LABEL_DECIMALS), { + font: TICK_LABEL_FONT, + fill: TrackLabColors.textProperty, + }), + }); + this.contentNode.addChild(xTickLabelSet); + + const yTickLabelSet = new TickLabelSet(chartTransform, Orientation.VERTICAL, initialSpacing, { + edge: "min", + createLabel: (value: number) => + new Text(value.toFixed(TICK_LABEL_DECIMALS), { + font: TICK_LABEL_FONT, + fill: TrackLabColors.textProperty, + }), + }); + this.contentNode.addChild(yTickLabelSet); + + this.gridConfig = { + verticalGridLineSet, + horizontalGridLineSet, + xTickMarkSet, + yTickMarkSet, + xTickLabelSet, + yTickLabelSet, + }; + + // ── Axis interaction regions ───────────────────────────────────────────── + this.yAxisInteractionRegion = new Rectangle(-Y_AXIS_INTERACTION_WIDTH, 0, Y_AXIS_INTERACTION_WIDTH, height, { + fill: "transparent", + pickable: true, + }); + this.contentNode.addChild(this.yAxisInteractionRegion); + + this.xAxisInteractionRegion = new Rectangle(0, height, width, X_AXIS_INTERACTION_HEIGHT, { + fill: "transparent", + pickable: true, + }); + this.contentNode.addChild(this.xAxisInteractionRegion); + + // ── Clipped data container ─────────────────────────────────────────────── + this.clippedDataContainer = new Node({ clipArea: Shape.rect(0, 0, width, height) }); + this.contentNode.addChild(this.clippedDataContainer); + + // ── Axis labels ────────────────────────────────────────────────────────── + this.xAxisLabelNode = new Text(formatAxisLabel(initialXProperty), { + font: AXIS_LABEL_FONT, + fill: TrackLabColors.textProperty, + centerX: width / 2, + top: height + AXIS_LABEL_OFFSET, + }); + this.contentNode.addChild(this.xAxisLabelNode); + + this.yAxisLabelNode = new Text(formatAxisLabel(initialYProperty), { + font: AXIS_LABEL_FONT, + fill: TrackLabColors.textProperty, + rotation: -Math.PI / 2, + centerY: height / 2, + right: -AXIS_LABEL_OFFSET, + }); + this.contentNode.addChild(this.yAxisLabelNode); + + // ── Control buttons ────────────────────────────────────────────────────── + const a11y = StringManager.getInstance().getA11y(); + + const createButton = (label: string, onClick: () => void, accessibleName?: TReadOnlyProperty): Node => { + const buttonText = new Text(label, { + font: BUTTON_FONT, + fill: TrackLabColors.controlPanelStrokeProperty, + }); + const buttonBackground = new Rectangle( + 0, + 0, + BUTTON_SIZE, + BUTTON_SIZE, + BUTTON_CORNER_RADIUS, + BUTTON_CORNER_RADIUS, + { + fill: TrackLabColors.controlPanelFillProperty, + stroke: TrackLabColors.controlPanelStrokeProperty, + cursor: "pointer", + }, + ); + const button = new Node({ + children: [buttonBackground, buttonText], + tagName: "button", + ...(accessibleName && { accessibleName }), + }); + buttonText.center = buttonBackground.center; + button.addInputListener({ + enter: () => { + buttonBackground.opacity = BUTTON_HOVER_OPACITY; + }, + exit: () => { + buttonBackground.opacity = 1.0; + }, + }); + button.addInputListener(new FireListener({ fire: onClick })); + return button; + }; + + const controlButtonsPanel = new HBox({ + children: [ + createButton("↻", callbacks.onRescale, a11y.graphRescaleStringProperty), + createButton("+", callbacks.onZoomIn, a11y.graphZoomInStringProperty), + createButton("−", callbacks.onZoomOut, a11y.graphZoomOutStringProperty), + createButton("←", () => callbacks.onPan("left"), a11y.graphPanLeftStringProperty), + createButton("→", () => callbacks.onPan("right"), a11y.graphPanRightStringProperty), + createButton("↑", () => callbacks.onPan("up"), a11y.graphPanUpStringProperty), + createButton("↓", () => callbacks.onPan("down"), a11y.graphPanDownStringProperty), + ], + spacing: BUTTON_SPACING, + left: BUTTON_PADDING, + top: BUTTON_PADDING, + }); + this.contentNode.addChild(controlButtonsPanel); + } + + /** + * Update rendering nodes after a graph resize. + * ConfigurableGraph calls this from its resizeGraph() method. + */ + public updateDimensions(newWidth: number, newHeight: number): void { + this.graphWidth = newWidth; + this.graphHeight = newHeight; + + this.clippedDataContainer.clipArea = Shape.rect(0, 0, newWidth, newHeight); + this.yAxisInteractionRegion.setRect(-Y_AXIS_INTERACTION_WIDTH, 0, Y_AXIS_INTERACTION_WIDTH, newHeight); + this.xAxisInteractionRegion.setRect(0, newHeight, newWidth, X_AXIS_INTERACTION_HEIGHT); + + this.xAxisLabelNode.centerX = newWidth / 2; + this.xAxisLabelNode.top = newHeight + AXIS_LABEL_OFFSET; + this.yAxisLabelNode.centerY = newHeight / 2; + } + + /** + * Refresh the axis label strings (called when the selected property changes). + */ + public updateXAxisLabel(property: PlottableProperty): void { + this.xAxisLabelNode.string = formatAxisLabel(property); + this.xAxisLabelNode.centerX = this.graphWidth / 2; + } + + public updateYAxisLabel(property: PlottableProperty): void { + this.yAxisLabelNode.string = formatAxisLabel(property); + this.yAxisLabelNode.centerY = this.graphHeight / 2; + } +} + +trackLab.register("GraphRenderer", GraphRenderer); diff --git a/src/screen-name/view/DataTableNode.ts b/src/screen-name/view/DataTableNode.ts index c66131c..a6c74ab 100644 --- a/src/screen-name/view/DataTableNode.ts +++ b/src/screen-name/view/DataTableNode.ts @@ -9,6 +9,12 @@ * Features: * - Scrollable both horizontally and vertically using DOM-based scrolling * - Export button to download data as CSV (export1.csv, export2.csv, ...) + * + * ## Architecture + * + * All HTML DOM construction and incremental-update state live in `TableRenderer`. + * This class focuses on SceneryStack integration: Panel, drag listeners, resize + * handles, and reactive property wiring. */ import { BooleanProperty, type TReadOnlyProperty } from "scenerystack/axon"; @@ -19,323 +25,59 @@ import { Panel } from "scenerystack/sun"; import { Tandem } from "scenerystack/tandem"; import { StringManager } from "../../i18n/StringManager.js"; import { createTrackLabButton, makeDownloadIcon } from "../../TrackLabButton.js"; -import TrackLabColors, { TRACK_COLORS } from "../../TrackLabColors.js"; +import TrackLabColors from "../../TrackLabColors.js"; import { OVERLAY_DRAG_SPEED, OVERLAY_SHIFT_DRAG_SPEED, PANEL_CORNER_RADIUS } from "../../TrackLabConstants.js"; import trackLab from "../../TrackLabNamespace.js"; -import type { Track } from "../model/Track.js"; -import { buildDataRows, type DataRow, generateCsv } from "../model/TrackExporter.js"; +import { generateCsv } from "../model/TrackExporter.js"; import type { TrackingModel } from "../model/TrackingModel.js"; - -// ── Accessibility ───────────────────────────────────────────────────────────── -// The HTML table gets a element for screen readers. The caption text -// is supplied by the caller so it can be localized. -type A11yLabels = { - tableCaption: string; -}; - -// ── Grid geometry ──────────────────────────────────────────────────────────── -// Scroll kicks in once the table exceeds these dimensions: -// width → beyond track B columns (Frame, Time, x(A), y(A), x(B), y(B)) -// height → beyond 10 data rows -const MAX_TABLE_WIDTH = 400; // px — approx. 6 columns before horizontal scroll -const MIN_TABLE_HEIGHT = 100; // Minimum height when little data -const MAX_TABLE_HEIGHT = 220; // px — approx. 10 rows before vertical scroll +import { type A11yLabels, type TableColors, type TableLabels, TableRenderer } from "./TableRenderer.js"; // ── Fonts ──────────────────────────────────────────────────────────────────── const TITLE_FONT = new PhetFont({ size: 12, weight: "bold" }); -const TABLE_FONT_SIZE = 11; // HTML table font size in px const EXPORT_BUTTON_FONT_SIZE = 9; -// ── Precision ───────────────────────────────────────────────────────────────── -// Both values are kept equal so exported CSV data matches what users see on screen. -const CELL_DECIMAL_PLACES = 4; // decimal places shown in on-screen table cells -const MIN_EMPTY_COL_COUNT = 4; // minimum columns (Frame, Time, x, y) when no tracks exist - // ── Panel layout ────────────────────────────────────────────────────────────── const PANEL_X_MARGIN = 10; const PANEL_Y_MARGIN = 10; -const CONTENT_SPACING = 6; // gap between title row and table DOM node -const TITLE_ROW_SPACING = 8; // gap between title label and export button +const CONTENT_SPACING = 6; +const TITLE_ROW_SPACING = 8; const EXPORT_BUTTON_ICON_SPACING = 3; // ── Resize handle geometry ──────────────────────────────────────────────────── const HANDLE_SIZE = 12; -const HANDLE_OFFSET = -6; // centers the 12px handle on each corner +const HANDLE_OFFSET = -6; const RESIZE_TOUCH_DILATION = 6; const RESIZE_MOUSE_DILATION = 4; -const MIN_TABLE_RESIZE_WIDTH = 200; // minimum table max-width during resize -const MIN_TABLE_RESIZE_HEIGHT = 80; // minimum table max-height during resize - -// ── HTML table CSS dimensions ───────────────────────────────────────────────── -const TABLE_WRAPPER_BORDER_RADIUS = 3; // px, border-radius on the scroll wrapper -const TABLE_HEADER_PADDING_Y = 4; // px, vertical padding in header cells -const TABLE_HEADER_PADDING_X = 8; // px, horizontal padding in header cells -const TABLE_EMPTY_CELL_PADDING_Y = 8; // px, vertical padding in the "no data" placeholder cell -const TABLE_EMPTY_CELL_PADDING_X = 16; // px, horizontal padding in the "no data" placeholder cell -const TABLE_CELL_PADDING_Y = 3; // px, vertical padding in data cells -const TABLE_CELL_PADDING_X = 6; // px, horizontal padding in data cells - -// ── Helpers ────────────────────────────────────────────────────────────────── -// DataRow, buildDataRows, and generateCsv are imported from TrackExporter. - -// Localized label strings for the HTML table -type TableLabels = { - frame: string; - timeSeconds: string; - noData: string; -}; - -// Color values for HTML table (cached CSS strings) -type TableColors = { - headerBg: string; - headerText: string; - rowOdd: string; - rowEven: string; - gridStroke: string; - emptyText: string; - symbolShadow: string; - background: string; -}; - -/** - * Build an HTML table element for the data. - * Height adjusts based on row count, with min/max constraints. - */ -function buildHtmlTable( - tracks: readonly Track[], - unit: string, - colors: TableColors, - labels: TableLabels, - a11y: A11yLabels, -): HTMLDivElement { - const dataRows = buildDataRows(tracks); - - const wrapper = document.createElement("div"); - wrapper.style.cssText = ` - overflow: auto; - width: max-content; - max-width: ${MAX_TABLE_WIDTH}px; - min-height: ${MIN_TABLE_HEIGHT}px; - max-height: ${MAX_TABLE_HEIGHT}px; - border: 1px solid ${colors.gridStroke}; - border-radius: ${TABLE_WRAPPER_BORDER_RADIUS}px; - background: ${colors.background}; - `; - - const table = document.createElement("table"); - table.style.cssText = ` - border-collapse: collapse; - font-family: Arial, sans-serif; - font-size: ${TABLE_FONT_SIZE}px; - white-space: nowrap; - `; - - // ── Accessible caption (visually hidden but read by screen readers) ──────── - const caption = document.createElement("caption"); - caption.textContent = a11y.tableCaption; - caption.style.cssText = - "position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap;"; - table.appendChild(caption); - - // ── Header row ───────────────────────────────────────────────────────────── - const thead = document.createElement("thead"); - const headerRow = document.createElement("tr"); - - const headerStyle = ` - background: ${colors.headerBg}; - color: ${colors.headerText}; - font-weight: bold; - padding: ${TABLE_HEADER_PADDING_Y}px ${TABLE_HEADER_PADDING_X}px; - border: 1px solid ${colors.gridStroke}; - text-align: center; - position: sticky; - top: 0; - z-index: 1; - `; - - const addHeaderCell = (content: string | HTMLElement, fullLabel?: string) => { - const th = document.createElement("th"); - th.scope = "col"; - if (typeof content === "string") { - th.textContent = content; - } else { - th.appendChild(content); - // Provide a plain-text aria-label when the header content is HTML (colored symbol) - if (fullLabel) { - th.setAttribute("aria-label", fullLabel); - } - } - th.style.cssText = headerStyle; - headerRow.appendChild(th); - }; - - /** - * Create a header cell with colored track symbol as a subscript. - * Format: "x_A(m)" where _A is rendered as a colored with the track color. - */ - const makeTrackHeader = (varName: string, track: Track): HTMLElement => { - const span = document.createElement("span"); - - // Variable name first - span.appendChild(document.createTextNode(varName)); - - // Colored subscript symbol - const sub = document.createElement("sub"); - const symbolSpan = document.createElement("span"); - symbolSpan.textContent = track.symbol; - symbolSpan.style.cssText = ` - color: ${TRACK_COLORS[track.colorIndex]?.toCSS() ?? "#000000"}; - font-weight: bold; - text-shadow: 0 0 2px ${colors.symbolShadow}; - `; - sub.appendChild(symbolSpan); - span.appendChild(sub); - - // Unit - span.appendChild(document.createTextNode(`(${unit})`)); - - return span; - }; - - addHeaderCell(labels.frame); - addHeaderCell(labels.timeSeconds); - - if (tracks.length === 0) { - // Placeholder columns when no tracks exist - addHeaderCell(`x (${unit})`); - addHeaderCell(`y (${unit})`); - } else { - for (const track of tracks) { - addHeaderCell(makeTrackHeader("x", track), `x_${track.symbol} (${unit})`); - addHeaderCell(makeTrackHeader("y", track), `y_${track.symbol} (${unit})`); - } - } - - thead.appendChild(headerRow); - table.appendChild(thead); - - // ── Data rows ────────────────────────────────────────────────────────────── - const tbody = document.createElement("tbody"); - - if (dataRows.length === 0) { - const tr = document.createElement("tr"); - const td = document.createElement("td"); - // Always at least MIN_EMPTY_COL_COUNT columns: Frame, Time, x, y - td.colSpan = Math.max(MIN_EMPTY_COL_COUNT, 2 + tracks.length * 2); - td.textContent = labels.noData; - td.style.cssText = ` - padding: ${TABLE_EMPTY_CELL_PADDING_Y}px ${TABLE_EMPTY_CELL_PADDING_X}px; - text-align: center; - color: ${colors.emptyText}; - font-style: italic; - `; - tr.appendChild(td); - tbody.appendChild(tr); - } else { - for (let i = 0; i < dataRows.length; i++) { - const row = dataRows[i]; - if (!row) { - continue; - } - const tr = document.createElement("tr"); - tr.style.background = i % 2 === 0 ? colors.rowOdd : colors.rowEven; - - const cellStyle = ` - padding: ${TABLE_CELL_PADDING_Y}px ${TABLE_CELL_PADDING_X}px; - border: 1px solid ${colors.gridStroke}; - text-align: center; - `; - - const addCell = (text: string) => { - const td = document.createElement("td"); - td.textContent = text; - td.style.cssText = cellStyle; - tr.appendChild(td); - }; - - addCell(String(row.frame)); - addCell(row.time.toFixed(CELL_DECIMAL_PLACES)); - - for (const track of tracks) { - const val = row.values.get(track.id); - if (val) { - addCell(val.x.toFixed(CELL_DECIMAL_PLACES)); - addCell(val.y.toFixed(CELL_DECIMAL_PLACES)); - } else { - addCell("—"); - addCell("—"); - } - } - - tbody.appendChild(tr); - } - } - - table.appendChild(tbody); - wrapper.appendChild(table); - - return wrapper; -} - -/** - * Build a single `` for a data row. - * Used by the incremental-update path to avoid rebuilding the whole table. - */ -function buildSingleDataRow( - row: DataRow, - tracks: readonly Track[], - cellStyle: string, - isEven: boolean, - colors: TableColors, -): HTMLTableRowElement { - const tr = document.createElement("tr"); - tr.style.background = isEven ? colors.rowEven : colors.rowOdd; - - const addCell = (text: string) => { - const td = document.createElement("td"); - td.textContent = text; - td.style.cssText = cellStyle; - tr.appendChild(td); +const MIN_TABLE_RESIZE_WIDTH = 200; +const MIN_TABLE_RESIZE_HEIGHT = 80; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function getTableColors(): TableColors { + return { + headerBg: TrackLabColors.tableHeaderBackgroundProperty.value.toCSS(), + headerText: TrackLabColors.tableHeaderTextProperty.value.toCSS(), + rowOdd: TrackLabColors.tableRowOddProperty.value.toCSS(), + rowEven: TrackLabColors.tableRowEvenProperty.value.toCSS(), + gridStroke: TrackLabColors.tableGridStrokeProperty.value.toCSS(), + emptyText: TrackLabColors.tableEmptyTextProperty.value.toCSS(), + symbolShadow: TrackLabColors.tableSymbolShadowProperty.value.toCSS(), + background: TrackLabColors.tableBackgroundProperty.value.toCSS(), }; - - addCell(String(row.frame)); - addCell(row.time.toFixed(CELL_DECIMAL_PLACES)); - for (const track of tracks) { - const val = row.values.get(track.id); - if (val) { - addCell(val.x.toFixed(CELL_DECIMAL_PLACES)); - addCell(val.y.toFixed(CELL_DECIMAL_PLACES)); - } else { - addCell("—"); - addCell("—"); - } - } - - return tr; } -// ── Component ──────────────────────────────────────────────────────────────── +// ── Component ───────────────────────────────────────────────────────────────── export class DataTableNode extends Node { private exportCounter = 1; - private tableWrapper: HTMLDivElement; + private readonly tableRenderer: TableRenderer; private readonly disposeDataTable: () => void; - // ── Resize state ───────────────────────────────────────────────────────── + // ── Resize state ────────────────────────────────────────────────────────── private readonly panel: Panel; - private currentMaxWidth = MAX_TABLE_WIDTH; - private currentMaxHeight = MAX_TABLE_HEIGHT; private readonly isResizingProperty = new BooleanProperty(false); private readonly resizeHandles: Rectangle[] = []; - // ── Incremental-update state ───────────────────────────────────────────── - // Tracks whether the table needs a full structural rebuild or just new rows. - private lastTrackIds: string[] = []; - private lastUnit: string = ""; - private tableBodyRef: HTMLTableSectionElement | null = null; - private readonly frameRowMap: Map = new Map(); - private maxRenderedFrame: number = -Infinity; - public constructor( tracking: TrackingModel, videoLoadedProperty: TReadOnlyProperty, @@ -354,39 +96,25 @@ export class DataTableNode extends Node { tableCaption: a11yStrings.dataTableStringProperty.value, }); - // Helper to get current table colors from properties - const getTableColors = (): TableColors => ({ - headerBg: TrackLabColors.tableHeaderBackgroundProperty.value.toCSS(), - headerText: TrackLabColors.tableHeaderTextProperty.value.toCSS(), - rowOdd: TrackLabColors.tableRowOddProperty.value.toCSS(), - rowEven: TrackLabColors.tableRowEvenProperty.value.toCSS(), - gridStroke: TrackLabColors.tableGridStrokeProperty.value.toCSS(), - emptyText: TrackLabColors.tableEmptyTextProperty.value.toCSS(), - symbolShadow: TrackLabColors.tableSymbolShadowProperty.value.toCSS(), - background: TrackLabColors.tableBackgroundProperty.value.toCSS(), - }); + // ── TableRenderer owns all DOM and incremental-update state ─────────────── + const tableRenderer = new TableRenderer(getTableColors(), getLabels(), getA11yLabels()); - // ── Create scrollable HTML table ───────────────────────────────────────── - const tableWrapper = buildHtmlTable([], "m", getTableColors(), getLabels(), getA11yLabels()); - const tableDomNode = new DOM(tableWrapper, { allowInput: true }); + const tableDomNode = new DOM(tableRenderer.wrapper, { allowInput: true }); // Notify Scenery whenever the wrapper's layout dimensions change so the // Panel reflows to match the growing/shrinking table content. const resizeObserver = new ResizeObserver(() => { tableDomNode.invalidateDOM(); }); - resizeObserver.observe(tableWrapper); + resizeObserver.observe(tableRenderer.wrapper); - // ── Export button ──────────────────────────────────────────────────────── + // ── Export button ───────────────────────────────────────────────────────── const exportButton = createTrackLabButton( new HBox({ children: [ makeDownloadIcon(), new Text(dataTableStrings.csvStringProperty, { - font: new PhetFont({ - size: EXPORT_BUTTON_FONT_SIZE, - weight: "bold", - }), + font: new PhetFont({ size: EXPORT_BUTTON_FONT_SIZE, weight: "bold" }), fill: TrackLabColors.textOnDarkProperty, }), ], @@ -399,8 +127,6 @@ export class DataTableNode extends Node { const tracks = tracking.tracksProperty.value; const unit = unitProperty.value; const csv = generateCsv(tracks, unit, getLabels()); - - // Create download — no DOM insertion needed in modern browsers. const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); @@ -408,13 +134,12 @@ export class DataTableNode extends Node { link.download = `export${this.exportCounter}.csv`; link.click(); URL.revokeObjectURL(url); - this.exportCounter++; }, }, ); - // ── Title row ──────────────────────────────────────────────────────────── + // ── Title row ───────────────────────────────────────────────────────────── const titleLabel = new Text(dataTableStrings.titleStringProperty, { font: TITLE_FONT, fill: TrackLabColors.textOnDarkProperty, @@ -426,7 +151,7 @@ export class DataTableNode extends Node { align: "center", }); - // ── Main content ───────────────────────────────────────────────────────── + // ── Main content ────────────────────────────────────────────────────────── const content = new VBox({ children: [titleRow, tableDomNode], spacing: CONTENT_SPACING, @@ -443,146 +168,42 @@ export class DataTableNode extends Node { super({ visible: false }); this.panel = panel; + this.tableRenderer = tableRenderer; this.addChild(panel); - this.tableWrapper = tableWrapper; - - // ── Full rebuild helper ────────────────────────────────────────────────── - // Replaces the entire table DOM and refreshes cached references. - const doFullRebuild = (tracks: readonly Track[], unit: string) => { - const colors = getTableColors(); - const newWrapper = buildHtmlTable(tracks, unit, colors, getLabels(), getA11yLabels()); - - this.tableWrapper.innerHTML = ""; - if (newWrapper.firstChild) { - this.tableWrapper.appendChild(newWrapper.firstChild); - } - // Copy all styles including the dynamic height - this.tableWrapper.style.cssText = newWrapper.style.cssText; - // Restore user-resized dimensions (cssText above resets them to the defaults) - this.tableWrapper.style.maxWidth = `${this.currentMaxWidth}px`; - this.tableWrapper.style.maxHeight = `${this.currentMaxHeight}px`; - - // Cache reference and rebuild the frame→row map. - const tbody = this.tableWrapper.querySelector("tbody"); - this.tableBodyRef = tbody instanceof HTMLTableSectionElement ? tbody : null; - this.frameRowMap.clear(); - this.maxRenderedFrame = -Infinity; - - if (this.tableBodyRef) { - const dataRows = buildDataRows(tracks); - const trs = Array.from(this.tableBodyRef.querySelectorAll("tr")); - dataRows.forEach((row, i) => { - const tr = trs[i]; - if (tr instanceof HTMLTableRowElement) { - this.frameRowMap.set(row.frame, tr); - if (row.frame > this.maxRenderedFrame) { - this.maxRenderedFrame = row.frame; - } - } - }); - } - - this.lastTrackIds = tracks.map((t) => t.id); - this.lastUnit = unit; + // ── Reactive updates ────────────────────────────────────────────────────── + const runUpdate = () => { + tableRenderer.update( + tracking.tracksProperty.value, + unitProperty.value, + getTableColors(), + getLabels(), + getA11yLabels(), + ); }; - // ── Rebuild table function ─────────────────────────────────────────────── - // Performs a full rebuild on structural changes (track added/removed, unit - // or colour change) and an incremental row-append on data-only changes - // (new points added to existing tracks). During auto-tracking this fires - // ~30 times/s, so avoiding unnecessary full DOM rebuilds is critical. - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: intentionally complex — must handle structural and incremental updates efficiently - const rebuildTable = () => { - const tracks = tracking.tracksProperty.value; - const unit = unitProperty.value; - const trackIds = tracks.map((t) => t.id); - - const isStructural = - unit !== this.lastUnit || - trackIds.length !== this.lastTrackIds.length || - trackIds.some((id, i) => id !== this.lastTrackIds[i]); - - if (isStructural || !this.tableBodyRef) { - doFullRebuild(tracks, unit); - return; - } - - // ── Incremental path: same track structure, only new points ──────────── - const dataRows = buildDataRows(tracks); - - // If any new row would be inserted before an already-rendered row the - // sort order of the table would break; fall back to full rebuild in that - // rare case (out-of-order manual digitizing on an earlier frame). - const hasOutOfOrder = dataRows.some( - (row) => !this.frameRowMap.has(row.frame) && row.frame < this.maxRenderedFrame, + const runRebuild = () => { + tableRenderer.rebuild( + tracking.tracksProperty.value, + unitProperty.value, + getTableColors(), + getLabels(), + getA11yLabels(), ); - if (hasOutOfOrder) { - doFullRebuild(tracks, unit); - return; - } - - const colors = getTableColors(); - const cellStyle = `padding: ${TABLE_CELL_PADDING_Y}px ${TABLE_CELL_PADDING_X}px; border: 1px solid ${colors.gridStroke}; text-align: center;`; - - // Remove the "no data" placeholder row when the first real rows arrive. - if (this.frameRowMap.size === 0 && dataRows.length > 0) { - this.tableBodyRef.innerHTML = ""; - } - - for (const row of dataRows) { - const tr = this.frameRowMap.get(row.frame); - if (tr !== undefined) { - // Update cells in an existing row (a second track filled in this frame). - const cells = tr.querySelectorAll("td"); - let cellIdx = 2; // skip Frame and Time columns - for (const track of tracks) { - const val = row.values.get(track.id); - const xCell = cells[cellIdx]; - const yCell = cells[cellIdx + 1]; - if (xCell) { - xCell.textContent = val ? val.x.toFixed(CELL_DECIMAL_PLACES) : "—"; - } - if (yCell) { - yCell.textContent = val ? val.y.toFixed(CELL_DECIMAL_PLACES) : "—"; - } - cellIdx += 2; - } - } else { - // Append a brand-new row at the bottom. - const rowIndex = this.frameRowMap.size; // 0-based index of this row - const newRow = buildSingleDataRow( - row, - tracks, - cellStyle, - rowIndex % 2 !== 0, // isEven flag: index 0 → rowOdd, index 1 → rowEven, … - colors, - ); - this.tableBodyRef.appendChild(newRow); - this.frameRowMap.set(row.frame, newRow); - if (row.frame > this.maxRenderedFrame) { - this.maxRenderedFrame = row.frame; - } - } - } }; - // ── Reactive updates ───────────────────────────────────────────────────── - const tracksListener = () => rebuildTable(); + const tracksListener = () => runUpdate(); tracking.tracksProperty.link(tracksListener); - const unitListener = () => rebuildTable(); + const unitListener = () => runUpdate(); unitProperty.link(unitListener); - // Colour profile and locale changes require a full rebuild because cell - // colours and label strings are baked into the DOM; they are not captured - // by the track-ID / unit structural-change check above. - const fullRebuild = () => doFullRebuild(tracking.tracksProperty.value, unitProperty.value); - - const tableHeaderBgListener = () => fullRebuild(); + // Color-theme and locale changes require a full rebuild because cell colours + // and label strings are baked into the DOM. + const tableHeaderBgListener = () => runRebuild(); TrackLabColors.tableHeaderBackgroundProperty.lazyLink(tableHeaderBgListener); - const frameStringListener = () => fullRebuild(); + const frameStringListener = () => runRebuild(); dataTableStrings.frameStringProperty.lazyLink(frameStringListener); const videoLoadedListener = (loaded: boolean) => { @@ -591,7 +212,6 @@ export class DataTableNode extends Node { videoLoadedProperty.link(videoLoadedListener); // ── Resize handles ──────────────────────────────────────────────────────── - // Four corner handles styled and positioned to match the graph resize handles. const resizeCorners = [ { cursor: "nwse-resize", accessibleName: a11yStrings.tableResizeTopLeftStringProperty }, { cursor: "nesw-resize", accessibleName: a11yStrings.tableResizeTopRightStringProperty }, @@ -615,42 +235,36 @@ export class DataTableNode extends Node { let dragStartState: { maxWidth: number; maxHeight: number; nodeX: number; nodeY: number } | null = null; let dragStartPointerPoint: Vector2 | null = null; - /** - * Apply a resize from the *current* stored dimensions. - * Used by keyboard drag (incremental per-frame deltas). - */ const applyIncrementalResize = (dx: number, dy: number) => { - let newMaxWidth = this.currentMaxWidth; - let newMaxHeight = this.currentMaxHeight; + let newMaxWidth = this.tableRenderer.getMaxWidth(); + let newMaxHeight = this.tableRenderer.getMaxHeight(); let deltaX = 0; let deltaY = 0; switch (cornerIndex) { case 0: // Top-left - newMaxWidth = Math.max(MIN_TABLE_RESIZE_WIDTH, this.currentMaxWidth - dx); - newMaxHeight = Math.max(MIN_TABLE_RESIZE_HEIGHT, this.currentMaxHeight - dy); - deltaX = this.currentMaxWidth - newMaxWidth; - deltaY = this.currentMaxHeight - newMaxHeight; + newMaxWidth = Math.max(MIN_TABLE_RESIZE_WIDTH, newMaxWidth - dx); + newMaxHeight = Math.max(MIN_TABLE_RESIZE_HEIGHT, newMaxHeight - dy); + deltaX = this.tableRenderer.getMaxWidth() - newMaxWidth; + deltaY = this.tableRenderer.getMaxHeight() - newMaxHeight; break; case 1: // Top-right - newMaxWidth = Math.max(MIN_TABLE_RESIZE_WIDTH, this.currentMaxWidth + dx); - newMaxHeight = Math.max(MIN_TABLE_RESIZE_HEIGHT, this.currentMaxHeight - dy); - deltaY = this.currentMaxHeight - newMaxHeight; + newMaxWidth = Math.max(MIN_TABLE_RESIZE_WIDTH, newMaxWidth + dx); + newMaxHeight = Math.max(MIN_TABLE_RESIZE_HEIGHT, newMaxHeight - dy); + deltaY = this.tableRenderer.getMaxHeight() - newMaxHeight; break; case 2: // Bottom-left - newMaxWidth = Math.max(MIN_TABLE_RESIZE_WIDTH, this.currentMaxWidth - dx); - newMaxHeight = Math.max(MIN_TABLE_RESIZE_HEIGHT, this.currentMaxHeight + dy); - deltaX = this.currentMaxWidth - newMaxWidth; + newMaxWidth = Math.max(MIN_TABLE_RESIZE_WIDTH, newMaxWidth - dx); + newMaxHeight = Math.max(MIN_TABLE_RESIZE_HEIGHT, newMaxHeight + dy); + deltaX = this.tableRenderer.getMaxWidth() - newMaxWidth; break; case 3: // Bottom-right - newMaxWidth = Math.max(MIN_TABLE_RESIZE_WIDTH, this.currentMaxWidth + dx); - newMaxHeight = Math.max(MIN_TABLE_RESIZE_HEIGHT, this.currentMaxHeight + dy); + newMaxWidth = Math.max(MIN_TABLE_RESIZE_WIDTH, newMaxWidth + dx); + newMaxHeight = Math.max(MIN_TABLE_RESIZE_HEIGHT, newMaxHeight + dy); break; } - this.currentMaxWidth = newMaxWidth; - this.currentMaxHeight = newMaxHeight; - this.applyTableDimensions(); + this.tableRenderer.applyDimensions(newMaxWidth, newMaxHeight); if (deltaX !== 0 || deltaY !== 0) { this.x += deltaX; @@ -664,8 +278,8 @@ export class DataTableNode extends Node { start: (event) => { handle.focus(); dragStartState = { - maxWidth: this.currentMaxWidth, - maxHeight: this.currentMaxHeight, + maxWidth: this.tableRenderer.getMaxWidth(), + maxHeight: this.tableRenderer.getMaxHeight(), nodeX: this.x, nodeY: this.y, }; @@ -676,7 +290,6 @@ export class DataTableNode extends Node { if (!(dragStartState && dragStartPointerPoint)) { return; } - // Pointer drag: snapshot-based for accuracy (no drift). const delta = event.pointer.point.minus(dragStartPointerPoint); let newMaxWidth = dragStartState.maxWidth; let newMaxHeight = dragStartState.maxHeight; @@ -706,9 +319,7 @@ export class DataTableNode extends Node { break; } - this.currentMaxWidth = newMaxWidth; - this.currentMaxHeight = newMaxHeight; - this.applyTableDimensions(); + this.tableRenderer.applyDimensions(newMaxWidth, newMaxHeight); if (deltaX !== 0 || deltaY !== 0) { this.x = dragStartState.nodeX + deltaX; @@ -744,19 +355,17 @@ export class DataTableNode extends Node { this.updateResizeHandlePositions(); - // Reposition handles whenever the panel reflows (table content or font changes) const localBoundsListener = () => { this.updateResizeHandlePositions(); }; panel.localBoundsProperty.lazyLink(localBoundsListener); - // Dim the panel while resizing for visual feedback const isResizingListener = (isResizing: boolean) => { this.opacity = isResizing ? 0.8 : 1.0; }; this.isResizingProperty.link(isResizingListener); - // ── Pan drag: lets the user freely reposition the panel ────────────────── + // ── Pan drag ────────────────────────────────────────────────────────────── let panStartPosition: Vector2 | null = null; let panStartPointerPoint: Vector2 | null = null; this.cursor = "grab"; @@ -781,7 +390,6 @@ export class DataTableNode extends Node { }), ); - // Store cleanup function this.disposeDataTable = () => { resizeObserver.disconnect(); tracking.tracksProperty.unlink(tracksListener); @@ -796,19 +404,6 @@ export class DataTableNode extends Node { }; } - /** - * Apply the current max dimensions to the table wrapper's CSS. - * Called during resize drags and after full rebuilds to restore user-set sizes. - */ - private applyTableDimensions(): void { - this.tableWrapper.style.maxWidth = `${this.currentMaxWidth}px`; - this.tableWrapper.style.maxHeight = `${this.currentMaxHeight}px`; - } - - /** - * Reposition the four corner resize handles to match the current panel bounds. - * Called after any resize or panel reflow. - */ private updateResizeHandlePositions(): void { if (this.resizeHandles.length === 0) { return; diff --git a/src/screen-name/view/TableRenderer.ts b/src/screen-name/view/TableRenderer.ts new file mode 100644 index 0000000..0703feb --- /dev/null +++ b/src/screen-name/view/TableRenderer.ts @@ -0,0 +1,410 @@ +/** + * TableRenderer — owns all HTML DOM construction and incremental update state + * for the kinematics data table. + * + * Extracted from DataTableNode so that the SceneryStack node stays focused on + * framework integration (Panel, drag listeners, resize handles) while this + * class handles the messy DOM details. + * + * ## Responsibilities + * - Build the initial scrollable wrapper + `` DOM structure + * - Decide on each update whether a full structural rebuild is needed or + * whether only new rows need to be appended (incremental path) + * - Own the state that tracks that decision: lastTrackIds, lastUnit, + * tableBodyRef, frameRowMap, maxRenderedFrame + * - Apply user-set max-width / max-height dimensions to the wrapper CSS + * + * ## Update strategy + * + * `update()` is called ~30 times/s during auto-tracking, so the incremental + * path (appending only new `` elements) is critical for performance. + * A full rebuild is triggered only when the table's structure changes: + * tracks added/removed, unit change, color-theme change, or locale change. + */ + +import { TRACK_COLORS } from "../../TrackLabColors.js"; +import type { Track } from "../model/Track.js"; +import { buildDataRows, type DataRow } from "../model/TrackExporter.js"; + +// ── Grid geometry ───────────────────────────────────────────────────────────── +export const MAX_TABLE_WIDTH = 400; +export const MIN_TABLE_HEIGHT = 100; +export const MAX_TABLE_HEIGHT = 220; + +// ── Precision ───────────────────────────────────────────────────────────────── +const CELL_DECIMAL_PLACES = 4; +const MIN_EMPTY_COL_COUNT = 4; + +// ── CSS dimensions ──────────────────────────────────────────────────────────── +const TABLE_FONT_SIZE = 11; +const TABLE_WRAPPER_BORDER_RADIUS = 3; +const TABLE_HEADER_PADDING_Y = 4; +const TABLE_HEADER_PADDING_X = 8; +const TABLE_EMPTY_CELL_PADDING_Y = 8; +const TABLE_EMPTY_CELL_PADDING_X = 16; +const TABLE_CELL_PADDING_Y = 3; +const TABLE_CELL_PADDING_X = 6; + +// ── Public types re-exported for DataTableNode ──────────────────────────────── + +export type TableColors = { + headerBg: string; + headerText: string; + rowOdd: string; + rowEven: string; + gridStroke: string; + emptyText: string; + symbolShadow: string; + background: string; +}; + +export type TableLabels = { + frame: string; + timeSeconds: string; + noData: string; +}; + +export type A11yLabels = { + tableCaption: string; +}; + +// ── Private helpers ─────────────────────────────────────────────────────────── + +function makeCellStyle(colors: TableColors): string { + return `padding: ${TABLE_CELL_PADDING_Y}px ${TABLE_CELL_PADDING_X}px; border: 1px solid ${colors.gridStroke}; text-align: center;`; +} + +function buildHtmlTable( + tracks: readonly Track[], + unit: string, + colors: TableColors, + labels: TableLabels, + a11y: A11yLabels, + maxWidth: number, + maxHeight: number, +): HTMLDivElement { + const dataRows = buildDataRows(tracks); + + const wrapper = document.createElement("div"); + wrapper.style.cssText = ` + overflow: auto; + width: max-content; + max-width: ${maxWidth}px; + min-height: ${MIN_TABLE_HEIGHT}px; + max-height: ${maxHeight}px; + border: 1px solid ${colors.gridStroke}; + border-radius: ${TABLE_WRAPPER_BORDER_RADIUS}px; + background: ${colors.background}; + `; + + const table = document.createElement("table"); + table.style.cssText = ` + border-collapse: collapse; + font-family: Arial, sans-serif; + font-size: ${TABLE_FONT_SIZE}px; + white-space: nowrap; + `; + + // Accessible caption (visually hidden) + const caption = document.createElement("caption"); + caption.textContent = a11y.tableCaption; + caption.style.cssText = + "position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap;"; + table.appendChild(caption); + + // ── Header ──────────────────────────────────────────────────────────────── + const headerStyle = ` + background: ${colors.headerBg}; + color: ${colors.headerText}; + font-weight: bold; + padding: ${TABLE_HEADER_PADDING_Y}px ${TABLE_HEADER_PADDING_X}px; + border: 1px solid ${colors.gridStroke}; + text-align: center; + position: sticky; + top: 0; + z-index: 1; + `; + + const thead = document.createElement("thead"); + const headerRow = document.createElement("tr"); + + const addHeaderCell = (content: string | HTMLElement, fullLabel?: string) => { + const th = document.createElement("th"); + th.scope = "col"; + if (typeof content === "string") { + th.textContent = content; + } else { + th.appendChild(content); + if (fullLabel) { + th.setAttribute("aria-label", fullLabel); + } + } + th.style.cssText = headerStyle; + headerRow.appendChild(th); + }; + + const makeTrackHeader = (varName: string, track: Track): HTMLElement => { + const span = document.createElement("span"); + span.appendChild(document.createTextNode(varName)); + const sub = document.createElement("sub"); + const symbolSpan = document.createElement("span"); + symbolSpan.textContent = track.symbol; + symbolSpan.style.cssText = ` + color: ${TRACK_COLORS[track.colorIndex]?.toCSS() ?? "#000000"}; + font-weight: bold; + text-shadow: 0 0 2px ${colors.symbolShadow}; + `; + sub.appendChild(symbolSpan); + span.appendChild(sub); + span.appendChild(document.createTextNode(`(${unit})`)); + return span; + }; + + addHeaderCell(labels.frame); + addHeaderCell(labels.timeSeconds); + if (tracks.length === 0) { + addHeaderCell(`x (${unit})`); + addHeaderCell(`y (${unit})`); + } else { + for (const track of tracks) { + addHeaderCell(makeTrackHeader("x", track), `x_${track.symbol} (${unit})`); + addHeaderCell(makeTrackHeader("y", track), `y_${track.symbol} (${unit})`); + } + } + thead.appendChild(headerRow); + table.appendChild(thead); + + // ── Data rows ───────────────────────────────────────────────────────────── + const tbody = document.createElement("tbody"); + if (dataRows.length === 0) { + const tr = document.createElement("tr"); + const td = document.createElement("td"); + td.colSpan = Math.max(MIN_EMPTY_COL_COUNT, 2 + tracks.length * 2); + td.textContent = labels.noData; + td.style.cssText = ` + padding: ${TABLE_EMPTY_CELL_PADDING_Y}px ${TABLE_EMPTY_CELL_PADDING_X}px; + text-align: center; + color: ${colors.emptyText}; + font-style: italic; + `; + tr.appendChild(td); + tbody.appendChild(tr); + } else { + for (let i = 0; i < dataRows.length; i++) { + const row = dataRows[i]; + if (!row) { + continue; + } + tbody.appendChild(buildDataRow(row, tracks, makeCellStyle(colors), i % 2 !== 0, colors)); + } + } + table.appendChild(tbody); + wrapper.appendChild(table); + return wrapper; +} + +function buildDataRow( + row: DataRow, + tracks: readonly Track[], + cellStyle: string, + isEven: boolean, + colors: TableColors, +): HTMLTableRowElement { + const tr = document.createElement("tr"); + tr.style.background = isEven ? colors.rowEven : colors.rowOdd; + + const addCell = (text: string) => { + const td = document.createElement("td"); + td.textContent = text; + td.style.cssText = cellStyle; + tr.appendChild(td); + }; + + addCell(String(row.frame)); + addCell(row.time.toFixed(CELL_DECIMAL_PLACES)); + for (const track of tracks) { + const val = row.values.get(track.id); + if (val) { + addCell(val.x.toFixed(CELL_DECIMAL_PLACES)); + addCell(val.y.toFixed(CELL_DECIMAL_PLACES)); + } else { + addCell("—"); + addCell("—"); + } + } + return tr; +} + +// ── TableRenderer ───────────────────────────────────────────────────────────── + +export class TableRenderer { + /** The scrollable wrapper div — embed this in a Scenery DOM node. */ + public readonly wrapper: HTMLDivElement; + + // ── Incremental-update state ─────────────────────────────────────────────── + private lastTrackIds: string[] = []; + private lastUnit: string = ""; + private tableBodyRef: HTMLTableSectionElement | null = null; + private readonly frameRowMap: Map = new Map(); + private maxRenderedFrame: number = -Infinity; + + // ── Dimension state ──────────────────────────────────────────────────────── + private currentMaxWidth: number; + private currentMaxHeight: number; + + public constructor(initialColors: TableColors, initialLabels: TableLabels, initialA11y: A11yLabels) { + this.currentMaxWidth = MAX_TABLE_WIDTH; + this.currentMaxHeight = MAX_TABLE_HEIGHT; + this.wrapper = buildHtmlTable( + [], + "m", + initialColors, + initialLabels, + initialA11y, + this.currentMaxWidth, + this.currentMaxHeight, + ); + } + + /** Apply user-set max-width / max-height to the wrapper CSS. */ + public applyDimensions(maxWidth: number, maxHeight: number): void { + this.currentMaxWidth = maxWidth; + this.currentMaxHeight = maxHeight; + this.wrapper.style.maxWidth = `${maxWidth}px`; + this.wrapper.style.maxHeight = `${maxHeight}px`; + } + + /** Current user-set max width. */ + public getMaxWidth(): number { + return this.currentMaxWidth; + } + + /** Current user-set max height. */ + public getMaxHeight(): number { + return this.currentMaxHeight; + } + + /** + * Full structural rebuild — replaces the entire table DOM. + * Must be called when tracks are added/removed, unit changes, + * color theme changes, or locale changes. + */ + public rebuild( + tracks: readonly Track[], + unit: string, + colors: TableColors, + labels: TableLabels, + a11y: A11yLabels, + ): void { + const newWrapper = buildHtmlTable(tracks, unit, colors, labels, a11y, this.currentMaxWidth, this.currentMaxHeight); + + this.wrapper.innerHTML = ""; + if (newWrapper.firstChild) { + this.wrapper.appendChild(newWrapper.firstChild); + } + // Copy styles (including dynamic height), then restore user-resized dimensions. + this.wrapper.style.cssText = newWrapper.style.cssText; + this.wrapper.style.maxWidth = `${this.currentMaxWidth}px`; + this.wrapper.style.maxHeight = `${this.currentMaxHeight}px`; + + // Rebuild cached references from the new DOM. + const tbody = this.wrapper.querySelector("tbody"); + this.tableBodyRef = tbody instanceof HTMLTableSectionElement ? tbody : null; + this.frameRowMap.clear(); + this.maxRenderedFrame = -Infinity; + + if (this.tableBodyRef) { + const dataRows = buildDataRows(tracks); + const trs = Array.from(this.tableBodyRef.querySelectorAll("tr")); + dataRows.forEach((row, i) => { + const tr = trs[i]; + if (tr instanceof HTMLTableRowElement) { + this.frameRowMap.set(row.frame, tr); + if (row.frame > this.maxRenderedFrame) { + this.maxRenderedFrame = row.frame; + } + } + }); + } + + this.lastTrackIds = tracks.map((t) => t.id); + this.lastUnit = unit; + } + + /** + * Smart update — performs a full rebuild on structural changes + * (track added/removed, unit or colour change) and an incremental + * row-append on data-only changes (new points on existing tracks). + * + * During auto-tracking this is called ~30 times/s, so avoiding + * unnecessary full DOM rebuilds is critical. + */ + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: intentionally complex — must handle structural and incremental updates efficiently + public update( + tracks: readonly Track[], + unit: string, + colors: TableColors, + labels: TableLabels, + a11y: A11yLabels, + ): void { + const trackIds = tracks.map((t) => t.id); + + const isStructural = + unit !== this.lastUnit || + trackIds.length !== this.lastTrackIds.length || + trackIds.some((id, i) => id !== this.lastTrackIds[i]); + + if (isStructural || !this.tableBodyRef) { + this.rebuild(tracks, unit, colors, labels, a11y); + return; + } + + // ── Incremental path: same track structure, only new points ────────────── + const dataRows = buildDataRows(tracks); + + // If any new row precedes the last rendered frame the sort order breaks; + // fall back to full rebuild (rare: out-of-order manual digitizing). + const hasOutOfOrder = dataRows.some((row) => !this.frameRowMap.has(row.frame) && row.frame < this.maxRenderedFrame); + if (hasOutOfOrder) { + this.rebuild(tracks, unit, colors, labels, a11y); + return; + } + + const cellStyle = makeCellStyle(colors); + + // Remove the "no data" placeholder row when the first real rows arrive. + if (this.frameRowMap.size === 0 && dataRows.length > 0) { + this.tableBodyRef.innerHTML = ""; + } + + for (const row of dataRows) { + const tr = this.frameRowMap.get(row.frame); + if (tr !== undefined) { + // Update cells in an existing row (a second track filled in this frame). + const cells = tr.querySelectorAll("td"); + let cellIdx = 2; // skip Frame and Time + for (const track of tracks) { + const val = row.values.get(track.id); + const xCell = cells[cellIdx]; + const yCell = cells[cellIdx + 1]; + if (xCell) { + xCell.textContent = val ? val.x.toFixed(CELL_DECIMAL_PLACES) : "—"; + } + if (yCell) { + yCell.textContent = val ? val.y.toFixed(CELL_DECIMAL_PLACES) : "—"; + } + cellIdx += 2; + } + } else { + // Append a brand-new row. + const rowIndex = this.frameRowMap.size; + const newRow = buildDataRow(row, tracks, cellStyle, rowIndex % 2 !== 0, colors); + this.tableBodyRef.appendChild(newRow); + this.frameRowMap.set(row.frame, newRow); + if (row.frame > this.maxRenderedFrame) { + this.maxRenderedFrame = row.frame; + } + } + } + } +}