Skip to content

Commit

Permalink
Initial pass at adding a toggle to show mode changes as a line chart (#…
Browse files Browse the repository at this point in the history
…1025)

* Initial pass at adding a toggle to show mode changes as a line chart
* Removed unused categorical checker and an older comment
* Moved state mode changes out of LayerXRange and into LayerLine
* Added the new property to the ui-view schema
* Changed view property to show as line plot, fixed an issue with tooltips and showing variants
* Added the separator back and fixed the resizing issue for the axis label
* Added totalWidth sum back and fixed more issues with latest merge
* Fixed an issue where multiple axis were not being shown
* Fixed another issue when drawing multiple xRange layers inside of a single row
* Removed link between xrange layer and axis
* Fixed an issue with only showing the first xrange axis label
  • Loading branch information
cohansen authored and JosephVolosin committed Oct 21, 2024
1 parent 041e766 commit 12d07cd
Show file tree
Hide file tree
Showing 9 changed files with 226 additions and 58 deletions.
75 changes: 63 additions & 12 deletions src/components/timeline/LayerLine.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

<script lang="ts">
import { quadtree as d3Quadtree, type Quadtree } from 'd3-quadtree';
import type { ScaleLinear, ScaleTime } from 'd3-scale';
import { scalePoint, type ScaleLinear, type ScalePoint, type ScaleTime } from 'd3-scale';
import { curveLinear, line as d3Line } from 'd3-shape';
import { createEventDispatcher, onMount, tick } from 'svelte';
import type { Resource } from '../../types/simulation';
import type { Axis, LinePoint, QuadtreePoint, ResourceLayerFilter, TimeRange } from '../../types/timeline';
import { getYScale, searchQuadtreePoint } from '../../utilities/timeline';
import { filterEmpty } from '../../utilities/generic';
import { CANVAS_PADDING_Y, getYScale, searchQuadtreePoint } from '../../utilities/timeline';
export let contextmenu: MouseEvent | undefined;
export let dpr: number = 1;
Expand All @@ -22,6 +23,7 @@
export let mouseout: MouseEvent | undefined;
export let pointRadius: number = 2;
export let resources: Resource[] = [];
export let showAsLinePlot: boolean = false;
export let viewTimeRange: TimeRange = { end: 0, start: 0 };
export let xScaleView: ScaleTime<number, number> | null = null;
export let yAxes: Axis[] = [];
Expand All @@ -33,9 +35,11 @@
let ctx: CanvasRenderingContext2D | null;
let mounted: boolean = false;
let quadtree: Quadtree<QuadtreePoint>;
let scaleDomain: Set<string> = new Set();
let visiblePointsById: Record<number, LinePoint> = {};
let yScale: ScaleLinear<number, number, never>;
let drawPointsRequest: number;
let stateLinePlotYScale: ScalePoint<string>;
let yScale: ScaleLinear<number, number, never>;
$: canvasHeightDpr = drawHeight * dpr;
$: canvasWidthDpr = drawWidth * dpr;
Expand All @@ -47,10 +51,11 @@
dpr &&
// TODO swap filter out for resources which are recomputed when the view changes (i.e. filter changes)
filter &&
lineColor &&
lineColor !== undefined &&
typeof lineWidth === 'number' &&
typeof pointRadius === 'number' &&
mounted &&
showAsLinePlot !== undefined &&
points &&
viewTimeRange &&
xScaleView &&
Expand Down Expand Up @@ -82,17 +87,41 @@
ctx.clearRect(0, 0, drawWidth, drawHeight);
const [yAxis] = yAxes.filter(axis => yAxisId === axis.id);
const domain = yAxis?.scaleDomain || [];
yScale = getYScale(domain, drawHeight);
quadtree = d3Quadtree<QuadtreePoint>()
.x(p => p.x)
.y(p => p.y)
.extent([
[0, 0],
[drawWidth, drawHeight],
]);
ctx.lineWidth = lineWidth;
ctx.strokeStyle = lineColor;
let line;
if (showAsLinePlot) {
const domain = Array.from(scaleDomain);
stateLinePlotYScale = scalePoint()
.domain(domain.filter(filterEmpty))
.range([drawHeight - CANVAS_PADDING_Y, CANVAS_PADDING_Y]) as ScalePoint<string>;
line = d3Line<LinePoint>()
.x(d => (xScaleView as ScaleTime<number, number, never>)(d.x))
.y(d => stateLinePlotYScale(d.y.toString()) as number)
.defined(d => d.y !== null) // Skip any gaps in resource data instead of interpolating
.curve(curveLinear);
} else {
const domain = yAxis?.scaleDomain || [];
yScale = getYScale(domain, drawHeight) as ScaleLinear<number, number, never>;
line = d3Line<LinePoint>()
.x(d => (xScaleView as ScaleTime<number, number, never>)(d.x))
.y(d => yScale(d.y) as number)
.defined(d => d.y !== null) // Skip any gaps in resource data instead of interpolating
.curve(curveLinear);
}
const line = d3Line<LinePoint>()
.x(d => (xScaleView as ScaleTime<number, number, never>)(d.x))
.y(d => yScale(d.y))
.defined(d => d.y !== null) // Skip any gaps in resource data instead of interpolating
.curve(curveLinear);
ctx.beginPath();
line.context(ctx)(points);
ctx.stroke();
Expand All @@ -119,7 +148,14 @@
for (const point of points) {
if (point.x >= viewTimeRange.start && point.x <= viewTimeRange.end) {
const x = (xScaleView as ScaleTime<number, number, never>)(point.x);
const y = yScale(point.y);
let y: number;
if (showAsLinePlot) {
y = stateLinePlotYScale(point.y.toString()) as number;
} else {
y = yScale(point.y) as number;
}
quadtree.add({ id: point.id, x, y });
visiblePointsById[point.id] = point;
ctx.drawImage(offscreenPoint, x - pointRadius, y - pointRadius, pointRadius * 2, pointRadius * 2);
Expand Down Expand Up @@ -186,6 +222,21 @@
y,
});
}
} else if (schema.type === 'string' || schema.type === 'variant') {
for (let i = 0; i < values.length; ++i) {
const value = values[i];
const { x } = value;
const y = value.y as number;
scaleDomain.add(value.y as string);
points.push({
id: id++,
name,
radius: radius,
type: 'line',
x,
y,
});
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/components/timeline/LayerXRange.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@
if (e) {
const { offsetX: x, offsetY: y } = e;
const points = searchQuadtreeRect<XRangePoint>(quadtree, x, y, drawHeight, maxXWidth, visiblePointsById);
dispatch('mouseOver', { e, layerId: id, points });
}
}
Expand Down
56 changes: 40 additions & 16 deletions src/components/timeline/Row.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@
import effects from '../../utilities/effects';
import { classNames } from '../../utilities/generic';
import { getDoyTime } from '../../utilities/time';
import { getYAxesWithScaleDomains, TimelineInteractionMode, type TimelineLockStatus } from '../../utilities/timeline';
import {
getYAxesWithScaleDomains,
isXRangeLayer,
TimelineInteractionMode,
type TimelineLockStatus,
} from '../../utilities/timeline';
import ConstraintViolations from './ConstraintViolations.svelte';
import LayerActivity from './LayerActivity.svelte';
import LayerGaps from './LayerGaps.svelte';
Expand Down Expand Up @@ -415,21 +420,40 @@
on:contextMenu
/>
{/if}
{#if layer.chartType === 'x-range'}
<LayerXRange
{...layer}
{contextmenu}
{dpr}
drawHeight={computedDrawHeight}
{drawWidth}
filter={layer.filter.resource}
{mousemove}
{mouseout}
resources={resourcesByViewLayerId[layer.id] ?? []}
{xScaleView}
on:mouseOver={onMouseOver}
on:contextMenu
/>
{#if isXRangeLayer(layer)}
{#if layer.showAsLinePlot === true}
<LayerLine
{...layer}
{contextmenu}
{dpr}
drawHeight={computedDrawHeight}
{drawWidth}
filter={layer.filter.resource}
{mousemove}
{mouseout}
resources={resourcesByViewLayerId[layer.id] ?? []}
{viewTimeRange}
{xScaleView}
yAxes={yAxesWithScaleDomains}
on:mouseOver={onMouseOver}
on:contextMenu
/>
{:else}
<LayerXRange
{...layer}
{contextmenu}
{dpr}
drawHeight={computedDrawHeight}
{drawWidth}
filter={layer.filter.resource}
{mousemove}
{mouseout}
resources={resourcesByViewLayerId[layer.id] ?? []}
{xScaleView}
on:mouseOver={onMouseOver}
on:contextMenu
/>
{/if}
{/if}
{/each}
</div>
Expand Down
101 changes: 76 additions & 25 deletions src/components/timeline/RowYAxes.svelte
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
<svelte:options immutable={true} />

<script lang="ts">
import { axisLeft as d3AxisLeft } from 'd3-axis';
import { axisLeft as d3AxisLeft, type AxisScale } from 'd3-axis';
import { format as d3Format } from 'd3-format';
import { select } from 'd3-selection';
import { select, type Selection } from 'd3-selection';
import { createEventDispatcher, tick } from 'svelte';
import type { Resource } from '../../types/simulation';
import type { Axis, Layer, LineLayer } from '../../types/timeline';
import { getYScale } from '../../utilities/timeline';
import type { Axis, Layer, LineLayer, XRangeLayer } from '../../types/timeline';
import { getOrdinalYScale, getYScale } from '../../utilities/timeline';
export let drawHeight: number = 0;
export let drawWidth: number = 0;
Expand All @@ -34,8 +34,55 @@
const axisClass = 'y-axis';
gSelection.selectAll(`.${axisClass}`).remove();
/**
* TODO: This is a temporary solution to showing state mode changes as a line chart.
* The correct way to do this would be generating a Y axes when the user toggles the line chart,
* but for now we're just setting the Y axes dynamically based on the data.
*/
const xRangeLayers = layers.filter(layer => layer.chartType === 'x-range');
let i = 0;
for (const layer of xRangeLayers) {
const resources = resourcesByViewLayerId[layer.id];
const xRangeAxisG = gSelection.append('g').attr('class', axisClass);
xRangeAxisG.selectAll('*').remove();
if ((layer as XRangeLayer).showAsLinePlot && resources && resources.length > 0) {
let domain: string[] = [];
// Get all the unique ordinal values of the chart.
for (const value of resources[0].values) {
if (domain.indexOf(value.y as string) === -1) {
domain.push(value.y as string);
}
}
const scale = getOrdinalYScale(domain, drawHeight);
// Don't do any special formatting here because we're dealing with strings.
const axisLeft = d3AxisLeft(scale as AxisScale<string>)
.tickSizeInner(0)
.tickSizeOuter(0)
.ticks(domain.length)
.tickPadding(2);
const axisMargin = 2;
const startPosition = -(totalWidth + axisMargin * i);
marginWidth += i > 0 ? axisMargin : 0;
xRangeAxisG.attr('transform', `translate(${startPosition}, 0)`);
xRangeAxisG.style('color', 'black');
xRangeAxisG.call(axisLeft);
xRangeAxisG.call(g => g.select('.domain').remove());
totalWidth += getBoundingClientRectWidth(xRangeAxisG.node());
}
drawSeparator(xRangeAxisG);
i++;
}
for (let i = 0; i < yAxes.length; ++i) {
const axis = yAxes[i];
const axisG = gSelection.append('g').attr('class', axisClass);
axisG.selectAll('*').remove();
// Get color for axis by examining associated layers. If more than one layer is associated,
// use the default axis color, otherwise use the color from the layer.
Expand All @@ -53,8 +100,6 @@
// const labelFontSize = axis.label?.fontSize || 12;
// const labelText = axis.label.text;
const tickCount = axis.tickCount || 1;
const axisG = gSelection.append('g').attr('class', axisClass);
axisG.selectAll('*').remove();
if (
tickCount > 0 &&
axis.scaleDomain &&
Expand Down Expand Up @@ -89,31 +134,37 @@
axisG.call(axisLeft);
axisG.call(g => g.select('.domain').remove());
}
// Draw separator
axisG
.append('line')
.attr('x1', 2)
.attr('y1', 0)
.attr('x2', 2)
.attr('y2', drawHeight)
.style('stroke', '#EBECEC')
.style('stroke-width', 2);
}
const axisGElement: SVGGElement | null = axisG.node();
if (axisGElement !== null) {
// TODO might be able to save minor perf by getting bounding rect of entire
// container instead of each individual axis?
totalWidth += axisGElement.getBoundingClientRect().width;
}
drawSeparator(axisG);
totalWidth += getBoundingClientRectWidth(axisG.node());
}
totalWidth += marginWidth;
if (totalWidth > 0 && drawWidth !== totalWidth) {
dispatch('updateYAxesWidth', totalWidth);
}
// Dispatch the width so the RowHeader can recalculate the label width.
dispatch('updateYAxesWidth', totalWidth);
}
}
function drawSeparator(axisG: Selection<SVGGElement, unknown, null, undefined>): void {
axisG
.append('line')
.attr('x1', 2)
.attr('y1', 0)
.attr('x2', 2)
.attr('y2', drawHeight)
.style('stroke', '#EBECEC')
.style('stroke-width', 2);
}
function getBoundingClientRectWidth(axisG: SVGGElement | null): number {
if (axisG !== null) {
return axisG.getBoundingClientRect().width + 4;
}
return 0;
}
</script>

<svg class="row-y-axes">
Expand Down
11 changes: 11 additions & 0 deletions src/components/timeline/form/TimelineEditorLayerSettings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,17 @@
on:input={onInput}
/>
</Input>
<Input layout="inline">
<label for="showAsLinePlot">Show As Line Plot</label>
<input
style:width="max-content"
checked={layerAsXRange.showAsLinePlot}
id="showAsLinePlot"
name="showAsLinePlot"
on:change={onInput}
type="checkbox"
/>
</Input>
{/if}
<Input layout="inline">
<label for="id">Layer ID</label>
Expand Down
3 changes: 3 additions & 0 deletions src/schemas/ui-view-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,9 @@
"opacity": {
"type": "number"
},
"showAsLinePlot": {
"type": "boolean"
},
"yAxisId": {
"$ref": "#/definitions/yAxisId"
}
Expand Down
1 change: 1 addition & 0 deletions src/types/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export type XRangeLayerColorScheme =
export interface XRangeLayer extends Layer {
colorScheme: XRangeLayerColorScheme;
opacity: number;
showAsLinePlot: boolean;
}

export interface XRangePoint extends Point {
Expand Down
Loading

0 comments on commit 12d07cd

Please sign in to comment.