Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial pass at adding a toggle to show mode changes as a line chart #1025

Merged
merged 13 commits into from
Dec 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
AaronPlave marked this conversation as resolved.
Show resolved Hide resolved
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;
AaronPlave marked this conversation as resolved.
Show resolved Hide resolved
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
Loading