@mhkeller/layercake-annotations 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +184 -0
- package/dist/Annotations.svelte +15 -0
- package/dist/Annotations.svelte.d.ts +14 -0
- package/dist/Editor.svelte +213 -0
- package/dist/Editor.svelte.d.ts +19 -0
- package/dist/README.md +200 -0
- package/dist/Static.svelte +24 -0
- package/dist/Static.svelte.d.ts +12 -0
- package/dist/components/AnnotationEditor.svelte +127 -0
- package/dist/components/AnnotationEditor.svelte.d.ts +14 -0
- package/dist/components/AnnotationsData.svelte +56 -0
- package/dist/components/AnnotationsData.svelte.d.ts +15 -0
- package/dist/components/ArrowZone.svelte +366 -0
- package/dist/components/ArrowZone.svelte.d.ts +27 -0
- package/dist/components/ArrowheadMarker.svelte +18 -0
- package/dist/components/ArrowheadMarker.svelte.d.ts +14 -0
- package/dist/components/Arrows.svelte +175 -0
- package/dist/components/Arrows.svelte.d.ts +20 -0
- package/dist/components/Draggable.svelte +121 -0
- package/dist/components/Draggable.svelte.d.ts +33 -0
- package/dist/components/EditableText.svelte +131 -0
- package/dist/components/EditableText.svelte.d.ts +16 -0
- package/dist/components/ResizeHandles.svelte +146 -0
- package/dist/components/ResizeHandles.svelte.d.ts +21 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +11 -0
- package/dist/modules/arrowUtils.d.ts +17 -0
- package/dist/modules/arrowUtils.js +32 -0
- package/dist/modules/coordinates.d.ts +85 -0
- package/dist/modules/coordinates.js +139 -0
- package/dist/modules/createRef.svelte.d.ts +7 -0
- package/dist/modules/createRef.svelte.js +10 -0
- package/dist/modules/filterObject.d.ts +7 -0
- package/dist/modules/filterObject.js +14 -0
- package/dist/modules/invertScale.d.ts +1 -0
- package/dist/modules/invertScale.js +5 -0
- package/dist/modules/newAnnotation.d.ts +9 -0
- package/dist/modules/newAnnotation.js +26 -0
- package/dist/modules/ordinalInvert.d.ts +9 -0
- package/dist/modules/ordinalInvert.js +22 -0
- package/dist/types.d.ts +162 -0
- package/package.json +61 -0
package/README.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# LayerCake Annotations
|
|
2
|
+
|
|
3
|
+
Add interactive text annotations with swoopy arrows to [LayerCake](https://layercake.graphics) charts.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @mhkeller/layercake-annotations
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```svelte
|
|
14
|
+
<script>
|
|
15
|
+
import { LayerCake } from 'layercake';
|
|
16
|
+
import { Annotations } from '@mhkeller/layercake-annotations';
|
|
17
|
+
|
|
18
|
+
let annotations = $state([]);
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<LayerCake data={data} x="date" y="value">
|
|
22
|
+
<Annotations bind:annotations />
|
|
23
|
+
</LayerCake>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Creating annotations:**
|
|
27
|
+
- Click anywhere on the chart to create
|
|
28
|
+
- Drag to reposition
|
|
29
|
+
- Double-click text to edit
|
|
30
|
+
- Hover + Delete/Backspace to remove
|
|
31
|
+
|
|
32
|
+
**Editing text:**
|
|
33
|
+
- Double-click to edit
|
|
34
|
+
- Enter to save
|
|
35
|
+
- Shift+Enter for line breaks
|
|
36
|
+
- Escape to cancel
|
|
37
|
+
|
|
38
|
+
**Formatting:**
|
|
39
|
+
- Cmd+click annotation to cycle text alignment: left → center → right
|
|
40
|
+
|
|
41
|
+
**Creating arrows:**
|
|
42
|
+
- Hover over annotation to reveal handles on west/east edges
|
|
43
|
+
- Drag a handle outward to create an arrow
|
|
44
|
+
- Cmd+click arrow to cycle through: curved clockwise → straight → curved counter-clockwise
|
|
45
|
+
|
|
46
|
+
## Props
|
|
47
|
+
|
|
48
|
+
| Prop | Type | Default | Description |
|
|
49
|
+
|------|------|---------|-------------|
|
|
50
|
+
| `annotations` | `Annotation[]` | `[]` | Array of annotation objects (bindable) |
|
|
51
|
+
| `editable` | `boolean` | `true` | Enable editing. Set `false` for read-only display |
|
|
52
|
+
|
|
53
|
+
## Annotation Data Structure
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
{
|
|
57
|
+
id: 0, // Unique identifier
|
|
58
|
+
date: new Date('2024-03-15'), // X value (matches LayerCake x accessor)
|
|
59
|
+
value: 42, // Y value (matches LayerCake y accessor)
|
|
60
|
+
dx: 5, // X offset: percentage of chart width (-100 to 100)
|
|
61
|
+
dy: -10, // Y offset: percentage of chart height (-100 to 100)
|
|
62
|
+
text: 'Peak value', // Annotation text (supports line breaks)
|
|
63
|
+
width: '120px', // Optional: fixed width
|
|
64
|
+
align: 'left', // Optional: 'left', 'center', or 'right'
|
|
65
|
+
arrows: [] // Array of arrows (see below)
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Offsets explained
|
|
70
|
+
|
|
71
|
+
The `dx` and `dy` values are **percentages of the chart dimensions** (not decimals 0–1).
|
|
72
|
+
|
|
73
|
+
| Value | Meaning |
|
|
74
|
+
|-------|---------|
|
|
75
|
+
| `dx: 0` | Annotation left edge aligned with data point |
|
|
76
|
+
| `dx: 10` | Shifted right by 10% of chart width |
|
|
77
|
+
| `dx: -5` | Shifted left by 5% of chart width |
|
|
78
|
+
| `dy: -15` | Shifted up by 15% of chart height |
|
|
79
|
+
|
|
80
|
+
### Arrow structure
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
{
|
|
84
|
+
side: 'east', // 'west' or 'east' - which side of annotation
|
|
85
|
+
clockwise: true, // true = clockwise curve, false = counter-clockwise, null = straight
|
|
86
|
+
source: {
|
|
87
|
+
dx: 12, // Pixels from annotation edge (horizontal)
|
|
88
|
+
dy: 15 // Pixels from annotation center (vertical)
|
|
89
|
+
},
|
|
90
|
+
target: {
|
|
91
|
+
date: new Date('2024-03-15'), // X data value (matches LayerCake x accessor)
|
|
92
|
+
value: 42, // Y data value
|
|
93
|
+
dx: 0, // % offset for ordinal X scales (0-100)
|
|
94
|
+
dy: 0 // % offset for ordinal Y scales (0-100)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Full Example
|
|
100
|
+
|
|
101
|
+
```svelte
|
|
102
|
+
<script>
|
|
103
|
+
import { LayerCake, Svg, Html } from 'layercake';
|
|
104
|
+
import { Annotations } from '@mhkeller/layercake-annotations';
|
|
105
|
+
import Line from './Line.svelte';
|
|
106
|
+
|
|
107
|
+
let data = [...];
|
|
108
|
+
let annotations = $state([
|
|
109
|
+
{
|
|
110
|
+
id: 0,
|
|
111
|
+
date: new Date('2024-06-01'),
|
|
112
|
+
value: 150,
|
|
113
|
+
dx: 2,
|
|
114
|
+
dy: -8,
|
|
115
|
+
text: 'Summer peak',
|
|
116
|
+
width: '100px',
|
|
117
|
+
arrows: [{
|
|
118
|
+
side: 'west',
|
|
119
|
+
clockwise: false,
|
|
120
|
+
source: { dx: -12, dy: 0 },
|
|
121
|
+
target: { date: new Date('2024-06-01'), value: 150, dx: 0, dy: 0 }
|
|
122
|
+
}]
|
|
123
|
+
}
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
let editable = $state(true);
|
|
127
|
+
</script>
|
|
128
|
+
|
|
129
|
+
<label>
|
|
130
|
+
<input type="checkbox" bind:checked={editable} /> Edit mode
|
|
131
|
+
</label>
|
|
132
|
+
|
|
133
|
+
<div class="chart-container">
|
|
134
|
+
<LayerCake {data} x="date" y="value">
|
|
135
|
+
<Svg><Line /></Svg>
|
|
136
|
+
<Annotations bind:annotations {editable} />
|
|
137
|
+
</LayerCake>
|
|
138
|
+
</div>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## TypeScript
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import type { Annotation, Arrow } from '@mhkeller/layercake-annotations/types';
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Components
|
|
148
|
+
|
|
149
|
+
| Export | Description |
|
|
150
|
+
|--------|-------------|
|
|
151
|
+
| `Annotations` | Main component – set `editable` prop to toggle modes |
|
|
152
|
+
| `AnnotationsEditor` | Edit mode only |
|
|
153
|
+
| `AnnotationsStatic` | Read-only mode only |
|
|
154
|
+
|
|
155
|
+
## Development
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
pnpm install
|
|
159
|
+
pnpm dev # Start dev server at localhost:5173
|
|
160
|
+
pnpm test # Run Playwright visual regression tests
|
|
161
|
+
pnpm package # Build for npm distribution
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Architecture
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
src/lib/
|
|
168
|
+
├── Annotations.svelte # Wrapper: switches Editor/Static based on editable
|
|
169
|
+
├── Editor.svelte # Edit mode: state management, context providers
|
|
170
|
+
├── Static.svelte # Read-only: renders annotations + arrows
|
|
171
|
+
├── components/
|
|
172
|
+
│ ├── AnnotationEditor # Draggable annotation with text editing
|
|
173
|
+
│ ├── AnnotationsData # Static annotation renderer
|
|
174
|
+
│ ├── ArrowZone # Handles for creating/editing arrows
|
|
175
|
+
│ ├── Arrows # SVG arrow path rendering
|
|
176
|
+
│ ├── Draggable # Drag behavior wrapper
|
|
177
|
+
│ ├── EditableText # Contenteditable text input
|
|
178
|
+
│ └── ResizeHandles # Width resize handles
|
|
179
|
+
├── modules/
|
|
180
|
+
│ ├── coordinates.js # Position calculations
|
|
181
|
+
│ ├── invertScale.js # Pixel → data value conversion
|
|
182
|
+
│ └── arrowUtils.js # SVG arc path generation
|
|
183
|
+
└── types.d.ts # TypeScript definitions
|
|
184
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
/** @typedef {import('./types.js').Annotation} Annotation */
|
|
3
|
+
|
|
4
|
+
import Editor from './Editor.svelte';
|
|
5
|
+
import Static from './Static.svelte';
|
|
6
|
+
|
|
7
|
+
/** @type {{ annotations?: Annotation[], editable?: boolean }} */
|
|
8
|
+
let { annotations = $bindable([]), editable = true } = $props();
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
{#if editable}
|
|
12
|
+
<Editor bind:annotations />
|
|
13
|
+
{:else}
|
|
14
|
+
<Static {annotations} />
|
|
15
|
+
{/if}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export default Annotations;
|
|
2
|
+
export type Annotation = import("./types.js").Annotation;
|
|
3
|
+
type Annotations = {
|
|
4
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
5
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
6
|
+
};
|
|
7
|
+
declare const Annotations: import("svelte").Component<{
|
|
8
|
+
annotations?: Annotation[];
|
|
9
|
+
editable?: boolean;
|
|
10
|
+
}, {}, "annotations">;
|
|
11
|
+
type $$ComponentProps = {
|
|
12
|
+
annotations?: Annotation[];
|
|
13
|
+
editable?: boolean;
|
|
14
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
/** @typedef {import('./types.js').Annotation} Annotation */
|
|
3
|
+
/** @typedef {import('./types.js').Arrow} Arrow */
|
|
4
|
+
/** @typedef {import('./types.js').HoverState} HoverState */
|
|
5
|
+
/** @typedef {import('./types.js').DragState} DragState */
|
|
6
|
+
/** @typedef {import('./types.js').SaveAnnotationConfigFn} SaveAnnotationConfigFn */
|
|
7
|
+
/**
|
|
8
|
+
* @template T
|
|
9
|
+
* @typedef {import('./types.js').Ref<T>} Ref
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getContext, setContext } from 'svelte';
|
|
13
|
+
import { Svg, Html } from 'layercake';
|
|
14
|
+
import { debounce } from 'underscore';
|
|
15
|
+
|
|
16
|
+
import AnnotationEditor from './components/AnnotationEditor.svelte';
|
|
17
|
+
import ArrowheadMarker from './components/ArrowheadMarker.svelte';
|
|
18
|
+
import Arrows from './components/Arrows.svelte';
|
|
19
|
+
|
|
20
|
+
import createRef from './modules/createRef.svelte.js';
|
|
21
|
+
import newAnnotation from './modules/newAnnotation.js';
|
|
22
|
+
|
|
23
|
+
/** @type {{ annotations?: Annotation[], containerClass?: string }} */
|
|
24
|
+
let { annotations: annos = $bindable([]), containerClass } = $props();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* LayerCake context
|
|
28
|
+
*/
|
|
29
|
+
const { xScale, yScale, config } = getContext('LayerCake');
|
|
30
|
+
|
|
31
|
+
/** @type {SaveAnnotationConfigFn | undefined} */
|
|
32
|
+
const saveAnnotationConfig = getContext('saveAnnotationConfig');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Save the config and log it for easy copy-paste
|
|
36
|
+
*/
|
|
37
|
+
const saveConfig_debounced = debounce((annos) => {
|
|
38
|
+
console.log('Annotations config:', JSON.stringify(annos, null, 2));
|
|
39
|
+
if (saveAnnotationConfig) {
|
|
40
|
+
saveAnnotationConfig(annos);
|
|
41
|
+
}
|
|
42
|
+
}, 1_000);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* State vars
|
|
46
|
+
*/
|
|
47
|
+
let idCounter = Math.max(...annos.map((d) => d.id), -1);
|
|
48
|
+
let annotations = $state(annos);
|
|
49
|
+
|
|
50
|
+
/** @type {Ref<boolean>} */
|
|
51
|
+
const isEditing = createRef(false);
|
|
52
|
+
|
|
53
|
+
/** @type {Ref<HoverState | null>} */
|
|
54
|
+
const hovering = createRef(null);
|
|
55
|
+
|
|
56
|
+
/** @type {Ref<boolean>} */
|
|
57
|
+
const moving = createRef(false);
|
|
58
|
+
|
|
59
|
+
/** @type {Ref<DragState | null>} - Preview arrow shown during drag */
|
|
60
|
+
const previewArrow = createRef(null);
|
|
61
|
+
|
|
62
|
+
setContext('isEditing', isEditing);
|
|
63
|
+
setContext('hovering', hovering);
|
|
64
|
+
setContext('moving', moving);
|
|
65
|
+
setContext('previewArrow', previewArrow);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Add a new annotation to the chart
|
|
69
|
+
*/
|
|
70
|
+
function onclick(e) {
|
|
71
|
+
if (isEditing.value === true) return;
|
|
72
|
+
|
|
73
|
+
const annotation = newAnnotation(e, ++idCounter, {
|
|
74
|
+
xScale: $xScale,
|
|
75
|
+
yScale: $yScale,
|
|
76
|
+
config: $config
|
|
77
|
+
});
|
|
78
|
+
annotations.push(annotation);
|
|
79
|
+
saveConfig_debounced(annotations);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Delete an annotation from the chart
|
|
84
|
+
*/
|
|
85
|
+
async function deleteAnnotation(id) {
|
|
86
|
+
annotations = annotations.filter((d) => d.id !== id);
|
|
87
|
+
saveConfig_debounced(annotations);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Modify the annotation's coordinates on drag
|
|
92
|
+
*/
|
|
93
|
+
function modifyAnnotation(id, newProps) {
|
|
94
|
+
annotations.forEach((d, i) => {
|
|
95
|
+
if (d.id === id) {
|
|
96
|
+
annotations[i] = {
|
|
97
|
+
...d,
|
|
98
|
+
...newProps
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
saveConfig_debounced(annotations);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Set or update an arrow on an annotation
|
|
107
|
+
* Arrow structure: { side, clockwise, source: { dx, dy }, target: { [xKey], [yKey] } }
|
|
108
|
+
*/
|
|
109
|
+
function setArrow(id, arrow) {
|
|
110
|
+
const annotation = annotations.find((d) => d.id === id);
|
|
111
|
+
if (!annotation) return;
|
|
112
|
+
|
|
113
|
+
const existingIndex = annotation.arrows.findIndex((a) => a.side === arrow.side);
|
|
114
|
+
|
|
115
|
+
if (existingIndex >= 0) {
|
|
116
|
+
annotation.arrows[existingIndex] = arrow;
|
|
117
|
+
} else {
|
|
118
|
+
annotation.arrows.push(arrow);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
saveConfig_debounced(annotations);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Modify an arrow's properties (e.g., clockwise)
|
|
126
|
+
*/
|
|
127
|
+
function modifyArrow(id, side, attrs) {
|
|
128
|
+
const annotation = annotations.find((d) => d.id === id);
|
|
129
|
+
if (!annotation) return;
|
|
130
|
+
|
|
131
|
+
const arrow = annotation.arrows.find((a) => a.side === side);
|
|
132
|
+
if (!arrow) return;
|
|
133
|
+
|
|
134
|
+
Object.assign(arrow, attrs);
|
|
135
|
+
saveConfig_debounced(annotations);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Delete an arrow from an annotation
|
|
140
|
+
*/
|
|
141
|
+
function deleteArrow(id, side) {
|
|
142
|
+
const annotation = annotations.find((d) => d.id === id);
|
|
143
|
+
if (!annotation) return;
|
|
144
|
+
|
|
145
|
+
const len = annotation.arrows.length;
|
|
146
|
+
annotation.arrows = annotation.arrows.filter((a) => a.side !== side);
|
|
147
|
+
|
|
148
|
+
// If we were hovering over an empty arrow zone, delete the annotation
|
|
149
|
+
if (len === annotation.arrows.length) {
|
|
150
|
+
deleteAnnotation(annotation.id);
|
|
151
|
+
}
|
|
152
|
+
saveConfig_debounced(annotations);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* If we press the delete key while hovering, delete the annotation or arrow
|
|
157
|
+
*/
|
|
158
|
+
function onkeydown(e) {
|
|
159
|
+
const hover = hovering.value;
|
|
160
|
+
if (!hover || isEditing.value === true) return;
|
|
161
|
+
|
|
162
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
163
|
+
if (hover.type === 'body') {
|
|
164
|
+
deleteAnnotation(hover.annotationId);
|
|
165
|
+
} else if (hover.type === 'arrow' && hover.side) {
|
|
166
|
+
deleteArrow(hover.annotationId, hover.side);
|
|
167
|
+
}
|
|
168
|
+
saveConfig_debounced(annotations);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Save our modifier functions to the context
|
|
174
|
+
*/
|
|
175
|
+
setContext('modifyAnnotation', modifyAnnotation);
|
|
176
|
+
setContext('setArrow', setArrow);
|
|
177
|
+
setContext('modifyArrow', modifyArrow);
|
|
178
|
+
</script>
|
|
179
|
+
|
|
180
|
+
{#snippet defs()}
|
|
181
|
+
<ArrowheadMarker />
|
|
182
|
+
{/snippet}
|
|
183
|
+
|
|
184
|
+
<Svg {defs}>
|
|
185
|
+
<Arrows {annotations} />
|
|
186
|
+
</Svg>
|
|
187
|
+
|
|
188
|
+
<Html>
|
|
189
|
+
<div
|
|
190
|
+
onclick={debounce(onclick, 250, true)}
|
|
191
|
+
onkeydown={(e) => e.key === 'Enter' && onclick(e)}
|
|
192
|
+
role="button"
|
|
193
|
+
tabindex="0"
|
|
194
|
+
aria-label="Click to add annotation"
|
|
195
|
+
class="note-listener"
|
|
196
|
+
></div>
|
|
197
|
+
|
|
198
|
+
<div class="layercake-annotations">
|
|
199
|
+
{#each annotations as d (d.id)}
|
|
200
|
+
<AnnotationEditor {d} {containerClass} />
|
|
201
|
+
{/each}
|
|
202
|
+
</div>
|
|
203
|
+
</Html>
|
|
204
|
+
|
|
205
|
+
<svelte:window {onkeydown} />
|
|
206
|
+
|
|
207
|
+
<style>
|
|
208
|
+
.note-listener {
|
|
209
|
+
width: 100%;
|
|
210
|
+
height: 100%;
|
|
211
|
+
cursor: copy;
|
|
212
|
+
}
|
|
213
|
+
</style>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export default Editor;
|
|
2
|
+
export type Annotation = import("./types.js").Annotation;
|
|
3
|
+
export type Arrow = import("./types.js").Arrow;
|
|
4
|
+
export type HoverState = import("./types.js").HoverState;
|
|
5
|
+
export type DragState = import("./types.js").DragState;
|
|
6
|
+
export type SaveAnnotationConfigFn = import("./types.js").SaveAnnotationConfigFn;
|
|
7
|
+
export type Ref<T> = import("./types.js").Ref<T>;
|
|
8
|
+
type Editor = {
|
|
9
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
10
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
11
|
+
};
|
|
12
|
+
declare const Editor: import("svelte").Component<{
|
|
13
|
+
annotations?: Annotation[];
|
|
14
|
+
containerClass?: string;
|
|
15
|
+
}, {}, "annotations">;
|
|
16
|
+
type $$ComponentProps = {
|
|
17
|
+
annotations?: Annotation[];
|
|
18
|
+
containerClass?: string;
|
|
19
|
+
};
|
package/dist/README.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# LayerCake Annotations - Internal Architecture
|
|
2
|
+
|
|
3
|
+
This document describes the internal architecture of the annotation library for contributors and maintainers.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
LayerCake Annotations provides interactive text annotations with swoopy arrows for [LayerCake](https://layercake.graphics/) charts. It supports both linear and ordinal scales.
|
|
8
|
+
|
|
9
|
+
## Component Structure
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
src/lib/
|
|
13
|
+
├── Editor.svelte # Interactive editing mode (exported as AnnotationsEditor)
|
|
14
|
+
├── Static.svelte # Read-only display mode (exported as AnnotationsStatic)
|
|
15
|
+
├── components/
|
|
16
|
+
│ ├── AnnotationEditor.svelte # Individual annotation wrapper
|
|
17
|
+
│ ├── ArrowZone.svelte # Draggable arrow handles
|
|
18
|
+
│ ├── Arrows.svelte # SVG arrow rendering
|
|
19
|
+
│ ├── Draggable.svelte # Generic drag behavior
|
|
20
|
+
│ ├── EditableText.svelte # Editable text input
|
|
21
|
+
│ ├── ResizeHandles.svelte # Width resize handles
|
|
22
|
+
│ ├── ArrowheadMarker.svelte # SVG arrowhead definition
|
|
23
|
+
│ └── AnnotationsData.svelte # Static annotations renderer
|
|
24
|
+
└── modules/
|
|
25
|
+
├── coordinates.js # Shared coordinate calculations
|
|
26
|
+
├── arrowUtils.js # SVG path generation
|
|
27
|
+
├── createRef.svelte.js # Reactive state refs for context
|
|
28
|
+
├── invertScale.js # Scale inversion utilities
|
|
29
|
+
├── ordinalInvert.js # Ordinal scale inversion
|
|
30
|
+
├── filterObject.js # Object utilities
|
|
31
|
+
└── newAnnotation.js # Annotation factory
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Data Model
|
|
35
|
+
|
|
36
|
+
### Annotation Object
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
{
|
|
40
|
+
id: number, // Unique identifier
|
|
41
|
+
[xKey]: any, // X data value (key from LayerCake config)
|
|
42
|
+
[yKey]: any, // Y data value (key from LayerCake config)
|
|
43
|
+
dx: number, // X offset: -100 to 100 (percentage of chart width)
|
|
44
|
+
dy: number, // Y offset: -100 to 100 (percentage of chart height)
|
|
45
|
+
text: string, // Annotation text (supports line breaks)
|
|
46
|
+
width?: string, // Box width (e.g., "150px")
|
|
47
|
+
align?: string, // Text alignment: 'left', 'center', or 'right'
|
|
48
|
+
arrows: Arrow[] // Attached arrows
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Note**: `dx` and `dy` are percentages (not decimals). Use `dx: 10` for 10% right, `dx: -5` for 5% left.
|
|
53
|
+
|
|
54
|
+
### Arrow Object
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
{
|
|
58
|
+
side: 'west' | 'east', // Which side of annotation the arrow originates from
|
|
59
|
+
clockwise: boolean|null, // true = clockwise arc, false = counter-clockwise, null = straight
|
|
60
|
+
source: {
|
|
61
|
+
dx: number, // Pixels from annotation edge (neg = further out)
|
|
62
|
+
dy: number // Pixels from annotation vertical center
|
|
63
|
+
},
|
|
64
|
+
target: {
|
|
65
|
+
[xKey]: any, // X data value (same accessor as LayerCake)
|
|
66
|
+
[yKey]: any, // Y data value
|
|
67
|
+
dx?: number, // 0-100: % offset within ordinal band (X)
|
|
68
|
+
dy?: number // 0-100: % offset within ordinal band (Y)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Coordinate System
|
|
74
|
+
|
|
75
|
+
The library uses multiple coordinate systems:
|
|
76
|
+
|
|
77
|
+
### Annotation Position
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
Final position = scale(dataValue) + (dx/100 × chartDimension)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
| Property | Unit | Range | Example |
|
|
84
|
+
|----------|------|-------|---------|
|
|
85
|
+
| `[xKey]` | Data value | Depends on data | `new Date('2024-01-15')` |
|
|
86
|
+
| `[yKey]` | Data value | Depends on data | `42` |
|
|
87
|
+
| `dx` | % of chart width | -100 to 100 | `5` = 5% right |
|
|
88
|
+
| `dy` | % of chart height | -100 to 100 | `-10` = 10% up |
|
|
89
|
+
|
|
90
|
+
### Arrow Source Position
|
|
91
|
+
|
|
92
|
+
Pixels relative to annotation box edge:
|
|
93
|
+
|
|
94
|
+
| Property | Unit | Meaning |
|
|
95
|
+
|----------|------|---------|
|
|
96
|
+
| `source.dx` | Pixels | Distance from annotation edge (west: left edge, east: right edge) |
|
|
97
|
+
| `source.dy` | Pixels | Distance from annotation vertical center |
|
|
98
|
+
|
|
99
|
+
### Arrow Target Position
|
|
100
|
+
|
|
101
|
+
| Property | Unit | When used |
|
|
102
|
+
|----------|------|-----------|
|
|
103
|
+
| `[xKey]`, `[yKey]` | Data values | Always (same keys as LayerCake config) |
|
|
104
|
+
| `dx`, `dy` | % within band (0-100) | Only for ordinal scales |
|
|
105
|
+
|
|
106
|
+
## Keyboard Shortcuts
|
|
107
|
+
|
|
108
|
+
| Shortcut | Action |
|
|
109
|
+
|----------|--------|
|
|
110
|
+
| Double-click | Edit annotation text |
|
|
111
|
+
| Enter | Save text and exit edit mode |
|
|
112
|
+
| Shift+Enter | Insert line break while editing |
|
|
113
|
+
| Escape | Cancel editing |
|
|
114
|
+
| Cmd+click | Cycle text alignment: left → center → right |
|
|
115
|
+
| Delete/Backspace | Delete hovered annotation or arrow |
|
|
116
|
+
| Cmd+click on arrow | Toggle curve direction: clockwise → straight → counter-clockwise |
|
|
117
|
+
|
|
118
|
+
## State Management
|
|
119
|
+
|
|
120
|
+
State is shared via Svelte context using the `createRef` pattern:
|
|
121
|
+
|
|
122
|
+
```javascript
|
|
123
|
+
// In Editor.svelte
|
|
124
|
+
const hovering = createRef(null);
|
|
125
|
+
setContext('hovering', hovering);
|
|
126
|
+
|
|
127
|
+
// In child components
|
|
128
|
+
const hovering = getContext('hovering');
|
|
129
|
+
hovering.value = { annotationId: 0, type: 'body' };
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Context Values
|
|
133
|
+
|
|
134
|
+
| Key | Type | Description |
|
|
135
|
+
|-----|------|-------------|
|
|
136
|
+
| `hovering` | `HoverState \| null` | Currently hovered element |
|
|
137
|
+
| `moving` | `boolean` | Whether dragging is in progress |
|
|
138
|
+
| `isEditing` | `boolean` | Whether text is being edited |
|
|
139
|
+
| `previewArrow` | `DragState \| null` | Live arrow during drag |
|
|
140
|
+
| `modifyAnnotation` | `function` | Update annotation props |
|
|
141
|
+
| `setArrow` | `function` | Create/update arrow |
|
|
142
|
+
| `modifyArrow` | `function` | Modify arrow properties |
|
|
143
|
+
|
|
144
|
+
## Key Modules
|
|
145
|
+
|
|
146
|
+
### coordinates.js
|
|
147
|
+
|
|
148
|
+
Centralizes all coordinate calculations to prevent drift:
|
|
149
|
+
|
|
150
|
+
```javascript
|
|
151
|
+
import {
|
|
152
|
+
getAnnotationBox, // Get annotation position/size
|
|
153
|
+
getArrowSource, // Get arrow source in pixels
|
|
154
|
+
getArrowTarget, // Get arrow target in pixels
|
|
155
|
+
calculateSourceDx, // Convert pixel to stored offset
|
|
156
|
+
DEFAULT_ANNOTATION_WIDTH
|
|
157
|
+
} from './modules/coordinates.js';
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### arrowUtils.js
|
|
161
|
+
|
|
162
|
+
Generates SVG arc paths:
|
|
163
|
+
|
|
164
|
+
```javascript
|
|
165
|
+
import { createArrowPath } from './modules/arrowUtils.js';
|
|
166
|
+
|
|
167
|
+
const path = createArrowPath(
|
|
168
|
+
{ x: 100, y: 50 }, // source
|
|
169
|
+
{ x: 200, y: 100 }, // target
|
|
170
|
+
true, // clockwise
|
|
171
|
+
Math.PI / 2 // angle (optional)
|
|
172
|
+
);
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Testing
|
|
176
|
+
|
|
177
|
+
Tests use Playwright for visual regression testing:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
pnpm test # Run tests
|
|
181
|
+
pnpm exec playwright test --update-snapshots # Update snapshots
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Tests cover both linear scale (`/`) and ordinal scale (`/ordinal`) examples.
|
|
185
|
+
|
|
186
|
+
## Common Tasks
|
|
187
|
+
|
|
188
|
+
### Adding a new arrow property
|
|
189
|
+
|
|
190
|
+
1. Update `Arrow` type in `types.d.ts`
|
|
191
|
+
2. Update `setArrow` in `Editor.svelte` to handle the property
|
|
192
|
+
3. Update `ArrowZone.svelte` to read/write the property
|
|
193
|
+
4. Update `Arrows.svelte` if it affects rendering
|
|
194
|
+
|
|
195
|
+
### Debugging coordinate issues
|
|
196
|
+
|
|
197
|
+
1. Check that `coordinates.js` functions are being used consistently
|
|
198
|
+
2. Verify the correct `scales` object is being passed
|
|
199
|
+
3. For east arrows, remember `dx` is from the RIGHT edge
|
|
200
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
/** @typedef {import('./types.js').Annotation} Annotation */
|
|
3
|
+
|
|
4
|
+
import { Svg, Html } from 'layercake';
|
|
5
|
+
|
|
6
|
+
import AnnotationsData from './components/AnnotationsData.svelte';
|
|
7
|
+
import ArrowheadMarker from './components/ArrowheadMarker.svelte';
|
|
8
|
+
import Arrows from './components/Arrows.svelte';
|
|
9
|
+
|
|
10
|
+
/** @type {{ annotations?: Annotation[] }} */
|
|
11
|
+
let { annotations = [] } = $props();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
{#snippet defs()}
|
|
15
|
+
<ArrowheadMarker />
|
|
16
|
+
{/snippet}
|
|
17
|
+
|
|
18
|
+
<Svg {defs}>
|
|
19
|
+
<Arrows {annotations} />
|
|
20
|
+
</Svg>
|
|
21
|
+
|
|
22
|
+
<Html>
|
|
23
|
+
<AnnotationsData {annotations} />
|
|
24
|
+
</Html>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export default Static;
|
|
2
|
+
export type Annotation = import("./types.js").Annotation;
|
|
3
|
+
type Static = {
|
|
4
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
5
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
6
|
+
};
|
|
7
|
+
declare const Static: import("svelte").Component<{
|
|
8
|
+
annotations?: Annotation[];
|
|
9
|
+
}, {}, "">;
|
|
10
|
+
type $$ComponentProps = {
|
|
11
|
+
annotations?: Annotation[];
|
|
12
|
+
};
|