Skip to content
Open
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
19 changes: 15 additions & 4 deletions src/components/Builder/BuilderApp.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
<script lang="ts">
import { encode } from '@abcnews/base-36-props';
import { BuilderStyleRoot, BuilderFrame, MarkerAdmin, UpdateChecker } from '@abcnews/components-builder';
import { Html, Svg } from 'layercake';
import Visualisation from '../Visualisation.svelte';
import { visState } from '../../lib/state.svelte';
import { onMount } from 'svelte';
import AnnotationDragHandler from './drag-handlers/AnnotationDragHandler.svelte';
import ArrowDragHandler from './drag-handlers/ArrowDragHandler.svelte';
import BuilderArrowHandles from './BuilderArrowHandles.svg.svelte';
import { getAxisDataType, loadMarkerConfig } from '../../lib/data-accessors';
import { isValiError } from 'valibot';
import ScreenshotTool from './ScreenshotTool/ScreenshotTool.svelte';
Expand Down Expand Up @@ -93,7 +97,17 @@
</script>

{#snippet Viz()}
<Visualisation {showConstructionMarks} />
<Visualisation {showConstructionMarks} {editorLayer} />
{/snippet}

{#snippet editorLayer()}
<Html>
<AnnotationDragHandler />
<ArrowDragHandler />
</Html>
<Svg>
<BuilderArrowHandles />
</Svg>
{/snippet}

{#snippet Sidebar()}
Expand Down Expand Up @@ -217,6 +231,3 @@
<BuilderFrame {Viz} {Sidebar} />
<UpdateChecker />
</BuilderStyleRoot>

<style>
</style>
63 changes: 63 additions & 0 deletions src/components/Builder/BuilderArrowHandles.svg.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { LayerCakeContextType } from '../../lib/types';
import { visState } from '../../lib/state.svelte';
import { getAxisDataType } from '../../lib/data-accessors';
import { coerceToColumnDataType } from '../../lib/data-helpers';

const { xScale, yScale, custom } = getContext<LayerCakeContextType>('LayerCake');

let arrows = $derived(visState.config.arrows.filter(d => !d.deleted));
let xAxisDataType = $derived(getAxisDataType(visState.config, 'x'));
let yAxisDataType = $derived(getAxisDataType(visState.config, 'y'));
</script>

<g class="builder-arrow-handles">
{#each arrows as arrow, i}
{@const fromX = $xScale(coerceToColumnDataType(arrow.from.x, xAxisDataType as any) as any)}
{@const fromY = $yScale(coerceToColumnDataType(arrow.from.y, yAxisDataType as any) as any)}
{@const toX = $xScale(coerceToColumnDataType(arrow.to.x, xAxisDataType as any) as any)}
{@const toY = $yScale(coerceToColumnDataType(arrow.to.y, yAxisDataType as any) as any)}

<circle
cx={fromX}
cy={fromY}
r="8"
class="handle"
class:visible={$custom.showConstructionMarks}
data-type="arrow"
data-index={i}
data-handle="from"
/>
<circle
cx={toX}
cy={toY}
r="8"
class="handle"
class:visible={$custom.showConstructionMarks}
data-type="arrow"
data-index={i}
data-handle="to"
/>
{/each}
</g>

<style>
.handle {
fill: AccentColor;
fill-opacity: 0;
stroke: AccentColor;
stroke-width: 1;
stroke-opacity: 0;
pointer-events: auto;
cursor: crosshair;
}
.handle.visible {
fill-opacity: 0.2;
stroke-opacity: 0.5;
}
.handle:hover {
fill-opacity: 0.5;
stroke-opacity: 1;
}
</style>
29 changes: 29 additions & 0 deletions src/components/Builder/drag-handlers/AnnotationDragHandler.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script lang="ts">
import { visState } from '../../../lib/state.svelte';
import { getAxisDataType } from '../../../lib/data-accessors';
import { coerceToColumnDataType } from '../../../lib/data-helpers';
import BaseDragHandler from './BaseDragHandler.svelte';

let xAxisDataType = $derived(getAxisDataType(visState.config, 'x'));
let yAxisDataType = $derived(getAxisDataType(visState.config, 'y'));
</script>

<BaseDragHandler
type="annotation"
{xAxisDataType}
{yAxisDataType}
coerceValue={coerceToColumnDataType}
getInitialPos={index => {
const filtered = visState.config.annotations.filter(d => !d.deleted);
const ann = filtered[index];
return ann ? { x: ann.x, y: ann.y } : null;
}}
onMove={(index, handle, x, y) => {
const filtered = visState.config.annotations.filter(d => !d.deleted);
const ann = filtered[index];
if (ann) {
ann.x = x;
ann.y = y;
}
}}
/>
35 changes: 35 additions & 0 deletions src/components/Builder/drag-handlers/ArrowDragHandler.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<script lang="ts">
import { visState } from '../../../lib/state.svelte';
import { getAxisDataType } from '../../../lib/data-accessors';
import { coerceToColumnDataType } from '../../../lib/data-helpers';
import BaseDragHandler from './BaseDragHandler.svelte';

let xAxisDataType = $derived(getAxisDataType(visState.config, 'x'));
let yAxisDataType = $derived(getAxisDataType(visState.config, 'y'));
</script>

<BaseDragHandler
type="arrow"
{xAxisDataType}
{yAxisDataType}
coerceValue={coerceToColumnDataType}
getInitialPos={(index, handle) => {
const filtered = visState.config.arrows.filter(d => !d.deleted);
const arrow = filtered[index];
if (!arrow) return null;
return handle === 'from' ? arrow.from : arrow.to;
}}
onMove={(index, handle, x, y) => {
const filtered = visState.config.arrows.filter(d => !d.deleted);
const arrow = filtered[index];
if (arrow) {
if (handle === 'from') {
arrow.from.x = x;
arrow.from.y = y;
} else {
arrow.to.x = x;
arrow.to.y = y;
}
}
}}
/>
153 changes: 153 additions & 0 deletions src/components/Builder/drag-handlers/BaseDragHandler.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<script lang="ts">
import { getContext } from 'svelte';
import { plotPadding } from '../../../lib/constants';
import type { LayerCakeContextType, ColumnTypesType } from '../../../lib/types';

const { xScale, yScale, width, height } = getContext<LayerCakeContextType>('LayerCake');

/**
* BaseDragHandler provides a system for dragging elements on a Chart.
* It handles coordinate inversion, grid snapping, grab-offsets, and cursor state.
*
* To implement a new drag handler:
* 1. Create a wrapper component.
* 2. Import and use `BaseDragHandler`.
* 3. Pass a unique `type` (matching a `data-type` on your draggable HTML/SVG elements).
* 4. Define `getInitialPos` and `onMove` to bridge the drag logic with your state.
*/

interface Props {
/** The unique identifier for the element type being dragged (matches `[data-type="${type}"]`). */
type: string;
/** The data type of the X axis (e.g. 'number', 'date') for coordinate inversion. */
xAxisDataType: ColumnTypesType | undefined;
/** The data type of the Y axis (e.g. 'number', 'date') for coordinate inversion. */
yAxisDataType: ColumnTypesType | undefined;
/** Returns the initial domain coordinates {x, y} for the element at the given index. */
getInitialPos: (index: number, handle: string | null) => { x: any; y: any } | null;
/** Callback triggered during drag to update the state with new domain coordinates. */
onMove: (index: number, handle: string | null, x: any, y: any) => void;
/** A function to coerce raw domain values into the correct column data type. */
coerceValue: (val: any, type: any) => any;
}

let { type, xAxisDataType, yAxisDataType, getInitialPos, onMove, coerceValue }: Props = $props();

/**
* Translates a pixel coordinate in the chart container into a domain value.
*/
const getDomainValue = (pixelValue: number, axis: 'x' | 'y', scale: any, dataType: ColumnTypesType | undefined) => {
if (typeof scale.invert !== 'function') {
return undefined;
}

const offset = axis === 'x' ? plotPadding.left || 0 : (plotPadding as any).top || 0;
const value: any = scale.invert(pixelValue - offset);

if (dataType === 'date' && value instanceof Date) {
return value.toISOString().split('T')[0];
}
if (dataType === 'number' && typeof value === 'number') {
return Number(value.toFixed(2));
}
return value;
};

/**
* Snaps a pixel value to the nearest grid increment.
*/
const snapToGrid = (value: number, step: number = 5) => {
return Math.round(value / step) * step;
};

type DragInfo = {
index: number;
handle: string | null;
grabOffsetX: number;
grabOffsetY: number;
};

let dragInfo = $state<DragInfo | null>(null);

const onmousedown = (e: MouseEvent) => {
const target = (e.target as HTMLElement).closest(`[data-type="${type}"]`);
if (!target || !target.closest('.visualisation')) return;

const index = Number(target.getAttribute('data-index'));
const handle = target.getAttribute('data-handle');

const rect = document.querySelector('.visualisation .layercake-container')?.getBoundingClientRect();
if (!rect) return;

const startX = e.clientX - rect.left;
const startY = e.clientY - rect.top;

const initialPos = getInitialPos(index, handle);
if (!initialPos) return;

const currentX = $xScale(coerceValue(initialPos.x, xAxisDataType)) + (plotPadding.left || 0);
const currentY = $yScale(coerceValue(initialPos.y, yAxisDataType)) + ((plotPadding as any).top || 0);

dragInfo = {
index,
handle,
grabOffsetX: startX - currentX,
grabOffsetY: startY - currentY
};

e.preventDefault();
e.stopPropagation();
};

const onmousemove = (e: MouseEvent) => {
if (!dragInfo) return;

const rect = document.querySelector('.visualisation .layercake-container')?.getBoundingClientRect();
if (!rect) return;

const rawX = e.clientX - rect.left - dragInfo.grabOffsetX;
const rawY = e.clientY - rect.top - dragInfo.grabOffsetY;

const snappedX = snapToGrid(rawX);
const snappedY = snapToGrid(rawY);

const domainX = getDomainValue(snappedX, 'x', $xScale, xAxisDataType);
const domainY = getDomainValue(snappedY, 'y', $yScale, yAxisDataType);

onMove(dragInfo.index, dragInfo.handle, domainX, domainY);
};

const onmouseup = () => {
dragInfo = null;
};
</script>

<svelte:window {onmousedown} {onmousemove} {onmouseup} />

{#if dragInfo}
<div class="drag-overlay" style:width="{$width}px" style:height="{$height}px"></div>
{/if}

<style>
/* Global overrides to enable builder interactivity without touching production components */
:global(.visualisation .layercake-layout-html),
:global(.visualisation .layercake-layout-svg) {
pointer-events: none;
}
:global(.visualisation [data-type]) {
pointer-events: auto;
cursor: move;
}
:global(.visualisation [data-handle]) {
cursor: crosshair;
}

.drag-overlay {
position: absolute;
top: 0;
left: 0;
z-index: 10000;
cursor: grabbing;
background: rgba(255, 255, 255, 0);
}
</style>
9 changes: 6 additions & 3 deletions src/components/Visualisation.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import Arrows from './layercake-components/Arrows.svg.svelte';
import BackgroundHighlight from './layercake-components/BackgroundHighlight.svelte';
import Lines from './layercake-components/Lines.svg.svelte';
import type { Snippet } from 'svelte';

import type {
CustomLayerCakeContextType,
Expand All @@ -34,6 +35,7 @@

interface Props {
showConstructionMarks?: boolean;
editorLayer?: Snippet;
}

// TODO: Move fetched and parsed data to a central state object from state.svelte.ts
Expand Down Expand Up @@ -114,7 +116,7 @@

let chartWidth: number = $state(0);

let { showConstructionMarks = false }: Props = $props();
let { showConstructionMarks = false, editorLayer }: Props = $props();

let xDomain = $derived(
getDomain(
Expand Down Expand Up @@ -181,8 +183,8 @@
x="x"
y="y"
z="series"
xDomain={xAxisDomainTween ? xAxisDomainTween.current : xDomain}
yDomain={yAxisDomainTween ? yAxisDomainTween.current : yDomain}
xDomain={(xAxisDomainTween ? xAxisDomainTween.current : xDomain) as any}
yDomain={(yAxisDomainTween ? yAxisDomainTween.current : yDomain) as any}
zScale={scaleOrdinal()}
zRange={seriesColors}
{flatData}
Expand Down Expand Up @@ -212,6 +214,7 @@
<Svg>
<Arrows {arrows} />
</Svg>
{@render editorLayer?.()}
</LayerCake>

{#if (visState.config.description && visState.config.description.length > 0) || visState.config.sources.length}
Expand Down
11 changes: 7 additions & 4 deletions src/components/layercake-components/Annotations.html.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,24 @@
annotations: (AnnotationType & DeletableType)[];
}

const { annotations }: Props = $props();
let { annotations }: Props = $props();
const annotationsFiltered = $derived(annotations.filter(d => !d.deleted));
const { xScale, yScale, custom } = getContext<LayerCakeContextType>('LayerCake');
let xAxisDataType = $derived(getAxisDataType(visState.config, 'x'));
let yAxisDataType = $derived(getAxisDataType(visState.config, 'y'));
</script>

{#if xAxisDataType && yAxisDataType}
{#each annotations.filter(d => !d.deleted) as annotation}
{#each annotationsFiltered as annotation, i}
<span
class="annotations__annotation"
data-type="annotation"
data-index={i}
transition:fade
style:--annotation-color={annotation.colour && annotation.colour.length > 3 ? annotation.colour : undefined}
class:show-construction-marks={$custom.showConstructionMarks}
style:left={`${$xScale(coerceToColumnDataType(annotation.x, xAxisDataType))}px`}
style:top={`${$yScale(coerceToColumnDataType(annotation.y, yAxisDataType))}px`}
style:left={`${$xScale(coerceToColumnDataType(annotation.x, xAxisDataType) as any)}px`}
style:top={`${$yScale(coerceToColumnDataType(annotation.y, yAxisDataType) as any)}px`}
style:width={`${annotation.width}em`}
class:middle={annotation.anchor === AnnotationAnchorType.Middle}
class:top-left={annotation.anchor === AnnotationAnchorType.TopLeft}
Expand Down
Loading