@svelteframe/shapes 0.0.0-stage → 0.0.1
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/LICENSE +21 -0
- package/README.md +34 -2
- package/dist/Arrow.svelte +76 -0
- package/dist/Arrow.svelte.d.ts +23 -0
- package/dist/Canvas.svelte +29 -0
- package/dist/Canvas.svelte.d.ts +9 -0
- package/dist/Circle.svelte +22 -0
- package/dist/Circle.svelte.d.ts +7 -0
- package/dist/Ellipse.svelte +25 -0
- package/dist/Ellipse.svelte.d.ts +10 -0
- package/dist/Line.svelte +47 -0
- package/dist/Line.svelte.d.ts +16 -0
- package/dist/Polygon.svelte +23 -0
- package/dist/Polygon.svelte.d.ts +10 -0
- package/dist/Rect.svelte +26 -0
- package/dist/Rect.svelte.d.ts +10 -0
- package/dist/Shape.svelte +49 -0
- package/dist/Shape.svelte.d.ts +15 -0
- package/dist/Star.svelte +24 -0
- package/dist/Star.svelte.d.ts +12 -0
- package/dist/Triangle.svelte +25 -0
- package/dist/Triangle.svelte.d.ts +8 -0
- package/dist/connect.d.ts +73 -0
- package/dist/connect.js +139 -0
- package/dist/ends.svelte.d.ts +10 -0
- package/dist/ends.svelte.js +40 -0
- package/dist/geometry.d.ts +102 -0
- package/dist/geometry.js +227 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/dist/registry.svelte.d.ts +20 -0
- package/dist/registry.svelte.js +39 -0
- package/dist/types.d.ts +33 -0
- package/dist/types.js +1 -0
- package/package.json +61 -3
package/dist/connect.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { anchorPoint, invertMatrix, shapeMatrix, transformPoint, transformVector } from './geometry.js';
|
|
2
|
+
/** The names an anchor can have, as written after the dot in `"card.bottom"`. */
|
|
3
|
+
export const ANCHORS = ['top-left', 'top', 'top-right', 'left', 'center', 'right', 'bottom-left', 'bottom', 'bottom-right'];
|
|
4
|
+
function checkAnchor(anchor, where) {
|
|
5
|
+
if (!ANCHORS.includes(anchor)) {
|
|
6
|
+
throw new Error(`Unknown anchor "${anchor}" in ${where}. Anchors are: ${ANCHORS.join(', ')}`);
|
|
7
|
+
}
|
|
8
|
+
return anchor;
|
|
9
|
+
}
|
|
10
|
+
/** Read what `from` or `to` was given as. */
|
|
11
|
+
export function parseEnd(end) {
|
|
12
|
+
if (Array.isArray(end)) {
|
|
13
|
+
const [x, y] = end;
|
|
14
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
15
|
+
throw new Error(`A point must be two finite numbers, got [${x}, ${y}]`);
|
|
16
|
+
return { kind: 'point', point: [x, y] };
|
|
17
|
+
}
|
|
18
|
+
if (typeof end === 'string') {
|
|
19
|
+
const dot = end.indexOf('.');
|
|
20
|
+
const id = dot < 0 ? end : end.slice(0, dot);
|
|
21
|
+
if (id === '')
|
|
22
|
+
throw new Error(`"${end}" does not name a shape`);
|
|
23
|
+
return { kind: 'shape', id, anchor: dot < 0 ? undefined : checkAnchor(end.slice(dot + 1), `"${end}"`), dx: 0, dy: 0 };
|
|
24
|
+
}
|
|
25
|
+
if (typeof end === 'object' && end !== null && typeof end.id === 'string') {
|
|
26
|
+
const { id, anchor, dx = 0, dy = 0 } = end;
|
|
27
|
+
if (id === '' || id.includes('.'))
|
|
28
|
+
throw new Error(`"${id}" is not a shape id`);
|
|
29
|
+
if (!Number.isFinite(dx) || !Number.isFinite(dy))
|
|
30
|
+
throw new Error('dx and dy must be finite numbers');
|
|
31
|
+
return { kind: 'shape', id, anchor: anchor === undefined ? undefined : checkAnchor(anchor, `the end of "${id}"`), dx, dy };
|
|
32
|
+
}
|
|
33
|
+
throw new Error('An end must be a point [x, y], an id like "card" or "card.bottom", or { id, anchor?, dx?, dy? }');
|
|
34
|
+
}
|
|
35
|
+
/** The ids a shape can be found by must not contain a dot, which separates the anchor. */
|
|
36
|
+
export function assertShapeId(id) {
|
|
37
|
+
if (id === '' || /[.\s]/.test(id))
|
|
38
|
+
throw new Error(`Invalid shape id "${id}": it must not be empty or contain dots or spaces`);
|
|
39
|
+
}
|
|
40
|
+
const matrixOf = (shape) => shapeMatrix({ ...shape.placement, width: shape.width, height: shape.height });
|
|
41
|
+
/** A point of the shape's box, on the canvas. */
|
|
42
|
+
export function shapeAnchorPoint(shape, anchor) {
|
|
43
|
+
return transformPoint(matrixOf(shape), anchorPoint(anchor, shape.width, shape.height));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* How far along `direction` (from the centre) the outline is, as a multiple of `direction`.
|
|
47
|
+
* `direction` is in the shape's own coordinates, which are centred on `(width / 2, height / 2)`.
|
|
48
|
+
*/
|
|
49
|
+
function distanceToOutline(shape, direction) {
|
|
50
|
+
const [dx, dy] = direction;
|
|
51
|
+
const halfWidth = shape.width / 2;
|
|
52
|
+
const halfHeight = shape.height / 2;
|
|
53
|
+
switch (shape.outline.kind) {
|
|
54
|
+
case 'box': {
|
|
55
|
+
const tx = dx === 0 ? Infinity : halfWidth / Math.abs(dx);
|
|
56
|
+
const ty = dy === 0 ? Infinity : halfHeight / Math.abs(dy);
|
|
57
|
+
return Math.min(tx, ty);
|
|
58
|
+
}
|
|
59
|
+
case 'ellipse': {
|
|
60
|
+
const k = Math.hypot(dx / halfWidth, dy / halfHeight);
|
|
61
|
+
return k === 0 ? Infinity : 1 / k;
|
|
62
|
+
}
|
|
63
|
+
case 'polygon': {
|
|
64
|
+
const { points } = shape.outline;
|
|
65
|
+
const centre = [halfWidth, halfHeight];
|
|
66
|
+
let best = Infinity;
|
|
67
|
+
for (let i = 0; i < points.length; i++) {
|
|
68
|
+
const a = points[i];
|
|
69
|
+
const b = points[(i + 1) % points.length];
|
|
70
|
+
// Where the ray centre + t * direction meets the edge a + u * (b - a).
|
|
71
|
+
const ex = b[0] - a[0];
|
|
72
|
+
const ey = b[1] - a[1];
|
|
73
|
+
const denominator = dx * ey - dy * ex;
|
|
74
|
+
if (Math.abs(denominator) < 1e-12)
|
|
75
|
+
continue;
|
|
76
|
+
const qx = a[0] - centre[0];
|
|
77
|
+
const qy = a[1] - centre[1];
|
|
78
|
+
const t = (qx * ey - qy * ex) / denominator;
|
|
79
|
+
const u = (qx * dy - qy * dx) / denominator;
|
|
80
|
+
if (t > 0 && u >= -1e-9 && u <= 1 + 1e-9)
|
|
81
|
+
best = Math.min(best, t);
|
|
82
|
+
}
|
|
83
|
+
return best;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Where the line from the centre of `shape` towards `target` leaves its outline. When the target
|
|
89
|
+
* is inside the shape there is no edge to meet, and the centre is returned.
|
|
90
|
+
*/
|
|
91
|
+
export function boundaryToward(shape, target) {
|
|
92
|
+
const matrix = matrixOf(shape);
|
|
93
|
+
const centre = transformPoint(matrix, [shape.width / 2, shape.height / 2]);
|
|
94
|
+
const inverse = invertMatrix(matrix);
|
|
95
|
+
if (!inverse)
|
|
96
|
+
return centre;
|
|
97
|
+
const direction = transformVector(inverse, [target[0] - centre[0], target[1] - centre[1]]);
|
|
98
|
+
if (direction[0] === 0 && direction[1] === 0)
|
|
99
|
+
return centre;
|
|
100
|
+
const t = distanceToOutline(shape, direction);
|
|
101
|
+
// Past the target (it is inside the shape), or no edge found.
|
|
102
|
+
if (!Number.isFinite(t) || t >= 1)
|
|
103
|
+
return centre;
|
|
104
|
+
return transformPoint(matrix, [shape.width / 2 + direction[0] * t, shape.height / 2 + direction[1] * t]);
|
|
105
|
+
}
|
|
106
|
+
/** Turn the `from` and `to` of a line into points on the canvas. */
|
|
107
|
+
export function resolveEnds(from, to, { find, gap = 0 }) {
|
|
108
|
+
if (!Number.isFinite(gap) || gap < 0)
|
|
109
|
+
throw new Error(`gap must be 0 or more, got ${gap}`);
|
|
110
|
+
const ends = [parseEnd(from), parseEnd(to)];
|
|
111
|
+
const shapes = ends.map((end) => (end.kind === 'shape' ? find(end.id) : undefined));
|
|
112
|
+
const missing = ends.flatMap((end, i) => (end.kind === 'shape' && !shapes[i] ? [end.id] : []));
|
|
113
|
+
if (missing.length > 0)
|
|
114
|
+
return { missing: [...new Set(missing)] };
|
|
115
|
+
// Where each end points to when the other end looks for its edge: the point itself, the
|
|
116
|
+
// chosen anchor of a shape, or the centre of one.
|
|
117
|
+
const reference = ends.map((end, i) => end.kind === 'point' ? end.point : shapeAnchorPoint(shapes[i], end.anchor ?? 'center'));
|
|
118
|
+
const resolved = ends.map((end, i) => {
|
|
119
|
+
if (end.kind === 'point')
|
|
120
|
+
return end.point;
|
|
121
|
+
const point = end.anchor === undefined ? boundaryToward(shapes[i], reference[1 - i]) : reference[i];
|
|
122
|
+
return [point[0] + end.dx, point[1] + end.dy];
|
|
123
|
+
});
|
|
124
|
+
if (gap > 0) {
|
|
125
|
+
const length = Math.hypot(resolved[1][0] - resolved[0][0], resolved[1][1] - resolved[0][1]);
|
|
126
|
+
if (length > 0) {
|
|
127
|
+
const ux = (resolved[1][0] - resolved[0][0]) / length;
|
|
128
|
+
const uy = (resolved[1][1] - resolved[0][1]) / length;
|
|
129
|
+
const attached = ends.map((end) => end.kind === 'shape');
|
|
130
|
+
const startGap = attached[0] ? gap : 0;
|
|
131
|
+
const endGap = attached[1] ? gap : 0;
|
|
132
|
+
// Never let the ends pass each other.
|
|
133
|
+
const room = Math.min(1, length / (startGap + endGap || 1));
|
|
134
|
+
resolved[0] = [resolved[0][0] + ux * startGap * room, resolved[0][1] + uy * startGap * room];
|
|
135
|
+
resolved[1] = [resolved[1][0] - ux * endGap * room, resolved[1][1] - uy * endGap * room];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { points: resolved, missing: [] };
|
|
139
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type End } from './connect.js';
|
|
2
|
+
import type { Point } from './geometry.js';
|
|
3
|
+
/**
|
|
4
|
+
* The two ends of a line or arrow on the canvas, following the shapes they are attached to.
|
|
5
|
+
* Call while a component is created. `ends.points` is `undefined` until every shape they name
|
|
6
|
+
* exists; one that never does fails the render, naming it and the ids there are.
|
|
7
|
+
*/
|
|
8
|
+
export declare function useEnds(component: string, from: () => End, to: () => End, gap: () => number | undefined): {
|
|
9
|
+
readonly points: readonly [Point, Point] | undefined;
|
|
10
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { delayRender, failRender } from '@svelteframe/core';
|
|
2
|
+
import { useCanvas } from './registry.svelte.js';
|
|
3
|
+
import { parseEnd, resolveEnds } from './connect.js';
|
|
4
|
+
/**
|
|
5
|
+
* The two ends of a line or arrow on the canvas, following the shapes they are attached to.
|
|
6
|
+
* Call while a component is created. `ends.points` is `undefined` until every shape they name
|
|
7
|
+
* exists; one that never does fails the render, naming it and the ids there are.
|
|
8
|
+
*/
|
|
9
|
+
export function useEnds(component, from, to, gap) {
|
|
10
|
+
const canvas = useCanvas();
|
|
11
|
+
const resolved = $derived.by(() => {
|
|
12
|
+
const [a, b] = [from(), to()];
|
|
13
|
+
if (!canvas) {
|
|
14
|
+
// Without a canvas there are no shapes to find, only points.
|
|
15
|
+
for (const end of [a, b]) {
|
|
16
|
+
const parsed = parseEnd(end);
|
|
17
|
+
if (parsed.kind === 'shape') {
|
|
18
|
+
throw new Error(`<${component}> is attached to "${parsed.id}", which needs a <Canvas> around them to be found`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return resolveEnds(a, b, { find: canvas?.find ?? (() => undefined), gap: gap() });
|
|
23
|
+
});
|
|
24
|
+
// Checked after the whole canvas is created, so a shape may come after the arrow in the markup.
|
|
25
|
+
$effect(() => {
|
|
26
|
+
const { missing } = resolved;
|
|
27
|
+
if (missing.length === 0)
|
|
28
|
+
return;
|
|
29
|
+
const known = canvas?.ids ?? [];
|
|
30
|
+
const names = missing.map((id) => `"${id}"`).join(', ');
|
|
31
|
+
const handle = delayRender(`<${component}> shapes`);
|
|
32
|
+
failRender(handle, new Error(`<${component}> is attached to ${names}, but no shape has ${missing.length === 1 ? 'that id' : 'those ids'}. ` +
|
|
33
|
+
(known.length > 0 ? `Shapes with an id: ${known.join(', ')}.` : 'No shape has an id: give one with the id prop.')));
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
get points() {
|
|
37
|
+
return resolved.points;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** A position in a shape's own coordinates, or on a `<Canvas>` (composition pixels, origin top-left). */
|
|
2
|
+
export type Point = readonly [x: number, y: number];
|
|
3
|
+
/** A point of a shape's box: where `x`/`y` put it, or what it rotates and scales around. */
|
|
4
|
+
export type Anchor = 'top-left' | 'top' | 'top-right' | 'left' | 'center' | 'right' | 'bottom-left' | 'bottom' | 'bottom-right';
|
|
5
|
+
/** Round away floating point noise, so the same input always prints the same attribute. */
|
|
6
|
+
export declare const round: (value: number) => number;
|
|
7
|
+
/** Where `anchor` is in a box of this size, measured from the box's top-left corner. */
|
|
8
|
+
export declare function anchorPoint(anchor: Anchor | Point, width: number, height: number): Point;
|
|
9
|
+
export interface TransformOptions {
|
|
10
|
+
/** Where the anchor point goes. */
|
|
11
|
+
x?: number;
|
|
12
|
+
y?: number;
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
/** Which point of the box goes to `x`/`y`. */
|
|
16
|
+
anchor?: Anchor | Point;
|
|
17
|
+
/** Degrees, clockwise. */
|
|
18
|
+
rotate?: number;
|
|
19
|
+
/** One factor, or one for each direction. */
|
|
20
|
+
scale?: number | readonly [x: number, y: number];
|
|
21
|
+
/** What `rotate` and `scale` are around. */
|
|
22
|
+
origin?: Anchor | Point;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The SVG `transform` that puts a shape's box where it belongs: the `anchor` point at `x`/`y`,
|
|
26
|
+
* then rotated and scaled around `origin`. Empty when the shape is where it is drawn.
|
|
27
|
+
*/
|
|
28
|
+
export declare function shapeTransform({ x, y, width, height, anchor, rotate, scale, origin }: TransformOptions): string;
|
|
29
|
+
/** The vertices as an SVG `points` attribute. */
|
|
30
|
+
export declare const pointsAttribute: (points: readonly Point[]) => string;
|
|
31
|
+
/**
|
|
32
|
+
* A regular polygon with a vertex pointing up, in a `2 * radius` box centred at `(radius, radius)`,
|
|
33
|
+
* so the box's centre is the polygon's centre.
|
|
34
|
+
*/
|
|
35
|
+
export declare function regularPolygon(sides: number, radius: number): Point[];
|
|
36
|
+
/** A star with a point up, in a `2 * outerRadius` box centred at `(outerRadius, outerRadius)`. */
|
|
37
|
+
export declare function star(points: number, outerRadius: number, innerRadius: number): Point[];
|
|
38
|
+
export declare function assertSize(name: string, value: number): void;
|
|
39
|
+
/** The point `t` of the way from `a` to `b`. */
|
|
40
|
+
export declare const lerpPoint: (a: Point, b: Point, t: number) => Point;
|
|
41
|
+
/** The smallest box around the points: its top-left corner and its size. */
|
|
42
|
+
export declare function boundsOf(points: readonly Point[]): {
|
|
43
|
+
x: number;
|
|
44
|
+
y: number;
|
|
45
|
+
width: number;
|
|
46
|
+
height: number;
|
|
47
|
+
};
|
|
48
|
+
/** `progress` limited to 0..1; anything that is not a number is an error. */
|
|
49
|
+
export declare function clampProgress(progress: number): number;
|
|
50
|
+
export type ArrowHeads = 'end' | 'start' | 'both' | 'none';
|
|
51
|
+
export interface ArrowOptions {
|
|
52
|
+
/** How much of the arrow is drawn, from `from` towards `to`. */
|
|
53
|
+
progress?: number;
|
|
54
|
+
strokeWidth: number;
|
|
55
|
+
/** Length of a head along the line. Defaults to 4 times the stroke width. */
|
|
56
|
+
headLength?: number;
|
|
57
|
+
/** Width of a head across the line. Defaults to 0.8 times its length. */
|
|
58
|
+
headWidth?: number;
|
|
59
|
+
heads?: ArrowHeads;
|
|
60
|
+
}
|
|
61
|
+
export interface ArrowGeometry {
|
|
62
|
+
/** The visible part of the shaft, ending where its heads begin. `undefined` when nothing is drawn. */
|
|
63
|
+
shaft?: readonly [Point, Point];
|
|
64
|
+
/** Each head as a triangle: tip first. */
|
|
65
|
+
heads: Point[][];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The parts of an arrow from `from` to `to`. A head sits on the end of what is drawn so far, and is
|
|
69
|
+
* shortened to fit while the arrow is still short, so an arrow can grow from nothing.
|
|
70
|
+
*/
|
|
71
|
+
export declare function arrowGeometry(from: Point, to: Point, options: ArrowOptions): ArrowGeometry;
|
|
72
|
+
export interface PaintOptions {
|
|
73
|
+
fill?: string;
|
|
74
|
+
stroke?: string;
|
|
75
|
+
strokeWidth?: number;
|
|
76
|
+
strokeLinecap?: 'butt' | 'round' | 'square';
|
|
77
|
+
strokeLinejoin?: 'miter' | 'round' | 'bevel';
|
|
78
|
+
opacity?: number;
|
|
79
|
+
/** How much of the outline is drawn, 0 to 1, from where it starts. */
|
|
80
|
+
progress?: number;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The presentation attributes for a shape's element. Left out when not given, so SVG's own
|
|
84
|
+
* defaults apply. `progress` draws only the first part of the outline: the element must then have
|
|
85
|
+
* `pathLength="1"`, which `needsPathLength` tells.
|
|
86
|
+
*/
|
|
87
|
+
export declare function paintAttributes({ progress, ...paint }: PaintOptions): Record<string, string | number>;
|
|
88
|
+
/** Whether `paintAttributes` drew only part of the outline, which needs `pathLength="1"`. */
|
|
89
|
+
export declare const needsPathLength: (progress: number | undefined) => boolean;
|
|
90
|
+
/** An affine transform `[a, b, c, d, e, f]`, as in SVG's `matrix(a b c d e f)`. */
|
|
91
|
+
export type Matrix = readonly [number, number, number, number, number, number];
|
|
92
|
+
/**
|
|
93
|
+
* What `shapeTransform` stands for, as a matrix: it takes a point of the shape, in the shape's own
|
|
94
|
+
* coordinates, to its place on the canvas.
|
|
95
|
+
*/
|
|
96
|
+
export declare function shapeMatrix(options: TransformOptions): Matrix;
|
|
97
|
+
export declare const identity: () => Matrix;
|
|
98
|
+
export declare const transformPoint: ([a, b, c, d, e, f]: Matrix, [x, y]: Point) => Point;
|
|
99
|
+
/** Apply only the rotation and scale: for directions, which have no position. */
|
|
100
|
+
export declare const transformVector: ([a, b, c, d]: Matrix, [x, y]: Point) => Point;
|
|
101
|
+
/** The matrix that undoes `matrix`, or `undefined` when it squashes the plane flat (a scale of 0). */
|
|
102
|
+
export declare function invertMatrix([a, b, c, d, e, f]: Matrix): Matrix | undefined;
|
package/dist/geometry.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/** Round away floating point noise, so the same input always prints the same attribute. */
|
|
2
|
+
export const round = (value) => Math.round(value * 1e4) / 1e4;
|
|
3
|
+
const FRACTIONS = {
|
|
4
|
+
'top-left': [0, 0],
|
|
5
|
+
top: [0.5, 0],
|
|
6
|
+
'top-right': [1, 0],
|
|
7
|
+
left: [0, 0.5],
|
|
8
|
+
center: [0.5, 0.5],
|
|
9
|
+
right: [1, 0.5],
|
|
10
|
+
'bottom-left': [0, 1],
|
|
11
|
+
bottom: [0.5, 1],
|
|
12
|
+
'bottom-right': [1, 1]
|
|
13
|
+
};
|
|
14
|
+
/** Where `anchor` is in a box of this size, measured from the box's top-left corner. */
|
|
15
|
+
export function anchorPoint(anchor, width, height) {
|
|
16
|
+
if (typeof anchor !== 'string')
|
|
17
|
+
return anchor;
|
|
18
|
+
const fraction = FRACTIONS[anchor];
|
|
19
|
+
if (!fraction)
|
|
20
|
+
throw new Error(`Unknown anchor "${anchor}"`);
|
|
21
|
+
return [width * fraction[0], height * fraction[1]];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The SVG `transform` that puts a shape's box where it belongs: the `anchor` point at `x`/`y`,
|
|
25
|
+
* then rotated and scaled around `origin`. Empty when the shape is where it is drawn.
|
|
26
|
+
*/
|
|
27
|
+
export function shapeTransform({ x = 0, y = 0, width, height, anchor = 'top-left', rotate = 0, scale = 1, origin = 'center' }) {
|
|
28
|
+
for (const [name, value] of [['x', x], ['y', y], ['rotate', rotate]]) {
|
|
29
|
+
if (!Number.isFinite(value))
|
|
30
|
+
throw new Error(`${name} must be a finite number, got ${value}`);
|
|
31
|
+
}
|
|
32
|
+
const [sx, sy] = typeof scale === 'number' ? [scale, scale] : scale;
|
|
33
|
+
if (!Number.isFinite(sx) || !Number.isFinite(sy))
|
|
34
|
+
throw new Error(`scale must be finite, got ${scale}`);
|
|
35
|
+
const [ax, ay] = anchorPoint(anchor, width, height);
|
|
36
|
+
const [ox, oy] = anchorPoint(origin, width, height);
|
|
37
|
+
const parts = [];
|
|
38
|
+
const tx = x - ax;
|
|
39
|
+
const ty = y - ay;
|
|
40
|
+
if (tx !== 0 || ty !== 0)
|
|
41
|
+
parts.push(`translate(${round(tx)} ${round(ty)})`);
|
|
42
|
+
if (rotate !== 0 || sx !== 1 || sy !== 1) {
|
|
43
|
+
parts.push(`translate(${round(ox)} ${round(oy)})`);
|
|
44
|
+
if (rotate !== 0)
|
|
45
|
+
parts.push(`rotate(${round(rotate)})`);
|
|
46
|
+
if (sx !== 1 || sy !== 1)
|
|
47
|
+
parts.push(`scale(${round(sx)} ${round(sy)})`);
|
|
48
|
+
parts.push(`translate(${round(-ox)} ${round(-oy)})`);
|
|
49
|
+
}
|
|
50
|
+
return parts.join(' ');
|
|
51
|
+
}
|
|
52
|
+
/** The vertices as an SVG `points` attribute. */
|
|
53
|
+
export const pointsAttribute = (points) => points.map(([x, y]) => `${round(x)},${round(y)}`).join(' ');
|
|
54
|
+
/**
|
|
55
|
+
* A regular polygon with a vertex pointing up, in a `2 * radius` box centred at `(radius, radius)`,
|
|
56
|
+
* so the box's centre is the polygon's centre.
|
|
57
|
+
*/
|
|
58
|
+
export function regularPolygon(sides, radius) {
|
|
59
|
+
if (!Number.isInteger(sides) || sides < 3)
|
|
60
|
+
throw new Error(`A polygon needs 3 or more sides, got ${sides}`);
|
|
61
|
+
assertSize('radius', radius);
|
|
62
|
+
return Array.from({ length: sides }, (_, i) => {
|
|
63
|
+
const angle = (-90 + (360 * i) / sides) * (Math.PI / 180);
|
|
64
|
+
return [radius + radius * Math.cos(angle), radius + radius * Math.sin(angle)];
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** A star with a point up, in a `2 * outerRadius` box centred at `(outerRadius, outerRadius)`. */
|
|
68
|
+
export function star(points, outerRadius, innerRadius) {
|
|
69
|
+
if (!Number.isInteger(points) || points < 3)
|
|
70
|
+
throw new Error(`A star needs 3 or more points, got ${points}`);
|
|
71
|
+
assertSize('outerRadius', outerRadius);
|
|
72
|
+
assertSize('innerRadius', innerRadius);
|
|
73
|
+
return Array.from({ length: points * 2 }, (_, i) => {
|
|
74
|
+
const radius = i % 2 === 0 ? outerRadius : innerRadius;
|
|
75
|
+
const angle = (-90 + (180 * i) / points) * (Math.PI / 180);
|
|
76
|
+
return [outerRadius + radius * Math.cos(angle), outerRadius + radius * Math.sin(angle)];
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
export function assertSize(name, value) {
|
|
80
|
+
if (!Number.isFinite(value) || value < 0)
|
|
81
|
+
throw new Error(`${name} must be 0 or more, got ${value}`);
|
|
82
|
+
}
|
|
83
|
+
/** The point `t` of the way from `a` to `b`. */
|
|
84
|
+
export const lerpPoint = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
|
85
|
+
/** The smallest box around the points: its top-left corner and its size. */
|
|
86
|
+
export function boundsOf(points) {
|
|
87
|
+
const xs = points.map((p) => p[0]);
|
|
88
|
+
const ys = points.map((p) => p[1]);
|
|
89
|
+
const x = Math.min(...xs);
|
|
90
|
+
const y = Math.min(...ys);
|
|
91
|
+
return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
|
|
92
|
+
}
|
|
93
|
+
/** `progress` limited to 0..1; anything that is not a number is an error. */
|
|
94
|
+
export function clampProgress(progress) {
|
|
95
|
+
if (Number.isNaN(progress))
|
|
96
|
+
throw new Error('progress must be a number');
|
|
97
|
+
return Math.min(1, Math.max(0, progress));
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The parts of an arrow from `from` to `to`. A head sits on the end of what is drawn so far, and is
|
|
101
|
+
* shortened to fit while the arrow is still short, so an arrow can grow from nothing.
|
|
102
|
+
*/
|
|
103
|
+
export function arrowGeometry(from, to, options) {
|
|
104
|
+
const { progress = 1, strokeWidth, heads: which = 'end' } = options;
|
|
105
|
+
assertSize('strokeWidth', strokeWidth);
|
|
106
|
+
const tip = lerpPoint(from, to, clampProgress(progress));
|
|
107
|
+
const dx = tip[0] - from[0];
|
|
108
|
+
const dy = tip[1] - from[1];
|
|
109
|
+
const length = Math.hypot(dx, dy);
|
|
110
|
+
if (length === 0)
|
|
111
|
+
return { heads: [] };
|
|
112
|
+
const wanted = options.headLength ?? strokeWidth * 4;
|
|
113
|
+
const wantedWidth = options.headWidth ?? wanted * 0.8;
|
|
114
|
+
assertSize('headLength', wanted);
|
|
115
|
+
assertSize('headWidth', wantedWidth);
|
|
116
|
+
const ux = dx / length;
|
|
117
|
+
const uy = dy / length;
|
|
118
|
+
const atStart = which === 'start' || which === 'both';
|
|
119
|
+
const atEnd = which === 'end' || which === 'both';
|
|
120
|
+
// Two heads may not be longer than the line between them.
|
|
121
|
+
const headLength = Math.min(wanted, length / (atStart && atEnd ? 2 : 1));
|
|
122
|
+
const headWidth = wanted === 0 ? 0 : wantedWidth * (headLength / wanted);
|
|
123
|
+
/** A head whose tip is `at`, pointing along `(dirX, dirY)`. */
|
|
124
|
+
const head = (at, dirX, dirY) => {
|
|
125
|
+
const base = [at[0] - dirX * headLength, at[1] - dirY * headLength];
|
|
126
|
+
const nx = -dirY * (headWidth / 2);
|
|
127
|
+
const ny = dirX * (headWidth / 2);
|
|
128
|
+
return [at, [base[0] + nx, base[1] + ny], [base[0] - nx, base[1] - ny]];
|
|
129
|
+
};
|
|
130
|
+
const heads = [];
|
|
131
|
+
let start = from;
|
|
132
|
+
let end = tip;
|
|
133
|
+
if (atStart) {
|
|
134
|
+
heads.push(head(from, -ux, -uy));
|
|
135
|
+
start = [from[0] + ux * headLength, from[1] + uy * headLength];
|
|
136
|
+
}
|
|
137
|
+
if (atEnd) {
|
|
138
|
+
heads.push(head(tip, ux, uy));
|
|
139
|
+
end = [tip[0] - ux * headLength, tip[1] - uy * headLength];
|
|
140
|
+
}
|
|
141
|
+
const shaftLength = Math.hypot(end[0] - start[0], end[1] - start[1]);
|
|
142
|
+
return { shaft: shaftLength > 1e-9 ? [start, end] : undefined, heads };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The presentation attributes for a shape's element. Left out when not given, so SVG's own
|
|
146
|
+
* defaults apply. `progress` draws only the first part of the outline: the element must then have
|
|
147
|
+
* `pathLength="1"`, which `needsPathLength` tells.
|
|
148
|
+
*/
|
|
149
|
+
export function paintAttributes({ progress, ...paint }) {
|
|
150
|
+
const attributes = {};
|
|
151
|
+
const set = (name, value) => {
|
|
152
|
+
if (value !== undefined)
|
|
153
|
+
attributes[name] = value;
|
|
154
|
+
};
|
|
155
|
+
set('fill', paint.fill);
|
|
156
|
+
set('stroke', paint.stroke);
|
|
157
|
+
set('stroke-width', paint.strokeWidth);
|
|
158
|
+
set('stroke-linecap', paint.strokeLinecap);
|
|
159
|
+
set('stroke-linejoin', paint.strokeLinejoin);
|
|
160
|
+
set('opacity', paint.opacity);
|
|
161
|
+
if (progress !== undefined) {
|
|
162
|
+
const p = clampProgress(progress);
|
|
163
|
+
if (p <= 0) {
|
|
164
|
+
// Nothing drawn yet; a round or square cap would still leave a dot.
|
|
165
|
+
attributes['stroke-dasharray'] = '0 1';
|
|
166
|
+
attributes['stroke-linecap'] = 'butt';
|
|
167
|
+
}
|
|
168
|
+
else if (p < 1)
|
|
169
|
+
attributes['stroke-dasharray'] = `${round(p)} 1`;
|
|
170
|
+
}
|
|
171
|
+
return attributes;
|
|
172
|
+
}
|
|
173
|
+
/** Whether `paintAttributes` drew only part of the outline, which needs `pathLength="1"`. */
|
|
174
|
+
export const needsPathLength = (progress) => progress !== undefined && clampProgress(progress) < 1;
|
|
175
|
+
const IDENTITY = [1, 0, 0, 1, 0, 0];
|
|
176
|
+
/** The transform that does `second` first and then `first`. */
|
|
177
|
+
function multiply(first, second) {
|
|
178
|
+
const [a1, b1, c1, d1, e1, f1] = first;
|
|
179
|
+
const [a2, b2, c2, d2, e2, f2] = second;
|
|
180
|
+
return [
|
|
181
|
+
a1 * a2 + c1 * b2,
|
|
182
|
+
b1 * a2 + d1 * b2,
|
|
183
|
+
a1 * c2 + c1 * d2,
|
|
184
|
+
b1 * c2 + d1 * d2,
|
|
185
|
+
a1 * e2 + c1 * f2 + e1,
|
|
186
|
+
b1 * e2 + d1 * f2 + f1
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
const translation = (x, y) => [1, 0, 0, 1, x, y];
|
|
190
|
+
/**
|
|
191
|
+
* What `shapeTransform` stands for, as a matrix: it takes a point of the shape, in the shape's own
|
|
192
|
+
* coordinates, to its place on the canvas.
|
|
193
|
+
*/
|
|
194
|
+
export function shapeMatrix(options) {
|
|
195
|
+
const { x = 0, y = 0, width, height, anchor = 'top-left', rotate = 0, scale = 1, origin = 'center' } = options;
|
|
196
|
+
// The same checks as the string version.
|
|
197
|
+
shapeTransform(options);
|
|
198
|
+
const [sx, sy] = typeof scale === 'number' ? [scale, scale] : scale;
|
|
199
|
+
const [ax, ay] = anchorPoint(anchor, width, height);
|
|
200
|
+
const [ox, oy] = anchorPoint(origin, width, height);
|
|
201
|
+
const angle = (rotate * Math.PI) / 180;
|
|
202
|
+
const cos = Math.cos(angle);
|
|
203
|
+
const sin = Math.sin(angle);
|
|
204
|
+
let matrix = translation(x - ax, y - ay);
|
|
205
|
+
matrix = multiply(matrix, translation(ox, oy));
|
|
206
|
+
matrix = multiply(matrix, [cos, sin, -sin, cos, 0, 0]);
|
|
207
|
+
matrix = multiply(matrix, [sx, 0, 0, sy, 0, 0]);
|
|
208
|
+
return multiply(matrix, translation(-ox, -oy));
|
|
209
|
+
}
|
|
210
|
+
export const identity = () => IDENTITY;
|
|
211
|
+
export const transformPoint = ([a, b, c, d, e, f], [x, y]) => [a * x + c * y + e, b * x + d * y + f];
|
|
212
|
+
/** Apply only the rotation and scale: for directions, which have no position. */
|
|
213
|
+
export const transformVector = ([a, b, c, d], [x, y]) => [a * x + c * y, b * x + d * y];
|
|
214
|
+
/** The matrix that undoes `matrix`, or `undefined` when it squashes the plane flat (a scale of 0). */
|
|
215
|
+
export function invertMatrix([a, b, c, d, e, f]) {
|
|
216
|
+
const determinant = a * d - b * c;
|
|
217
|
+
if (Math.abs(determinant) < 1e-12)
|
|
218
|
+
return undefined;
|
|
219
|
+
return [
|
|
220
|
+
d / determinant,
|
|
221
|
+
-b / determinant,
|
|
222
|
+
-c / determinant,
|
|
223
|
+
a / determinant,
|
|
224
|
+
(c * f - d * e) / determinant,
|
|
225
|
+
(b * e - a * f) / determinant
|
|
226
|
+
];
|
|
227
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { default as Arrow } from './Arrow.svelte';
|
|
2
|
+
export { default as Canvas } from './Canvas.svelte';
|
|
3
|
+
export { default as Circle } from './Circle.svelte';
|
|
4
|
+
export { default as Ellipse } from './Ellipse.svelte';
|
|
5
|
+
export { default as Line } from './Line.svelte';
|
|
6
|
+
export { default as Polygon } from './Polygon.svelte';
|
|
7
|
+
export { default as Rect } from './Rect.svelte';
|
|
8
|
+
export { default as Star } from './Star.svelte';
|
|
9
|
+
export { default as Triangle } from './Triangle.svelte';
|
|
10
|
+
export { ANCHORS, boundaryToward, parseEnd, resolveEnds, shapeAnchorPoint, type End, type Outline, type ResolvedEnds, type ShapeGeometry } from './connect.js';
|
|
11
|
+
export { anchorPoint, arrowGeometry, regularPolygon, shapeTransform, star, type Anchor, type ArrowGeometry, type ArrowHeads, type ArrowOptions, type Point, type TransformOptions } from './geometry.js';
|
|
12
|
+
export type { ShapeProps } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { default as Arrow } from './Arrow.svelte';
|
|
2
|
+
export { default as Canvas } from './Canvas.svelte';
|
|
3
|
+
export { default as Circle } from './Circle.svelte';
|
|
4
|
+
export { default as Ellipse } from './Ellipse.svelte';
|
|
5
|
+
export { default as Line } from './Line.svelte';
|
|
6
|
+
export { default as Polygon } from './Polygon.svelte';
|
|
7
|
+
export { default as Rect } from './Rect.svelte';
|
|
8
|
+
export { default as Star } from './Star.svelte';
|
|
9
|
+
export { default as Triangle } from './Triangle.svelte';
|
|
10
|
+
export { ANCHORS, boundaryToward, parseEnd, resolveEnds, shapeAnchorPoint } from './connect.js';
|
|
11
|
+
export { anchorPoint, arrowGeometry, regularPolygon, shapeTransform, star } from './geometry.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ShapeGeometry } from './connect.js';
|
|
2
|
+
/**
|
|
3
|
+
* The shapes of a `<Canvas>` that have an `id`, so lines and arrows can attach to them. A shape is
|
|
4
|
+
* registered as soon as it is created, from its props alone, so an arrow is right on the first
|
|
5
|
+
* frame, wherever in the markup the shape is.
|
|
6
|
+
*/
|
|
7
|
+
export declare class Registry {
|
|
8
|
+
#private;
|
|
9
|
+
/** `geometry` is read again whenever what it reads changes, so a moving shape is followed. */
|
|
10
|
+
register(id: string, geometry: () => ShapeGeometry): void;
|
|
11
|
+
/** Forget a shape, unless another one has taken its id since. */
|
|
12
|
+
unregister(id: string, geometry: () => ShapeGeometry): void;
|
|
13
|
+
find: (id: string) => ShapeGeometry | undefined;
|
|
14
|
+
/** The ids there are now. */
|
|
15
|
+
get ids(): string[];
|
|
16
|
+
}
|
|
17
|
+
/** Provided by `<Canvas>`: shapes inside it are placed in composition pixels. */
|
|
18
|
+
export declare function setCanvas(): Registry;
|
|
19
|
+
/** The enclosing `<Canvas>`'s shapes, or `undefined` outside one. */
|
|
20
|
+
export declare function useCanvas(): Registry | undefined;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
3
|
+
import { assertShapeId } from './connect.js';
|
|
4
|
+
/**
|
|
5
|
+
* The shapes of a `<Canvas>` that have an `id`, so lines and arrows can attach to them. A shape is
|
|
6
|
+
* registered as soon as it is created, from its props alone, so an arrow is right on the first
|
|
7
|
+
* frame, wherever in the markup the shape is.
|
|
8
|
+
*/
|
|
9
|
+
export class Registry {
|
|
10
|
+
#shapes = new SvelteMap();
|
|
11
|
+
/** `geometry` is read again whenever what it reads changes, so a moving shape is followed. */
|
|
12
|
+
register(id, geometry) {
|
|
13
|
+
assertShapeId(id);
|
|
14
|
+
if (this.#shapes.has(id))
|
|
15
|
+
throw new Error(`Two shapes have the id "${id}": ids must be unique within a <Canvas>`);
|
|
16
|
+
this.#shapes.set(id, geometry);
|
|
17
|
+
}
|
|
18
|
+
/** Forget a shape, unless another one has taken its id since. */
|
|
19
|
+
unregister(id, geometry) {
|
|
20
|
+
if (this.#shapes.get(id) === geometry)
|
|
21
|
+
this.#shapes.delete(id);
|
|
22
|
+
}
|
|
23
|
+
find = (id) => this.#shapes.get(id)?.();
|
|
24
|
+
/** The ids there are now. */
|
|
25
|
+
get ids() {
|
|
26
|
+
return [...this.#shapes.keys()];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const CANVAS_KEY = Symbol('svelteframe.shapes.canvas');
|
|
30
|
+
/** Provided by `<Canvas>`: shapes inside it are placed in composition pixels. */
|
|
31
|
+
export function setCanvas() {
|
|
32
|
+
const registry = new Registry();
|
|
33
|
+
setContext(CANVAS_KEY, registry);
|
|
34
|
+
return registry;
|
|
35
|
+
}
|
|
36
|
+
/** The enclosing `<Canvas>`'s shapes, or `undefined` outside one. */
|
|
37
|
+
export function useCanvas() {
|
|
38
|
+
return getContext(CANVAS_KEY);
|
|
39
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Anchor, Point } from './geometry.js';
|
|
2
|
+
/** What every shape takes. */
|
|
3
|
+
export interface ShapeProps {
|
|
4
|
+
/**
|
|
5
|
+
* A name that lines and arrows in the same `<Canvas>` can attach to, as `from="id"` or
|
|
6
|
+
* `from="id.bottom"`. Unique, without dots or spaces; read once.
|
|
7
|
+
*/
|
|
8
|
+
id?: string;
|
|
9
|
+
/**
|
|
10
|
+
* In a `<Canvas>`, where the `anchor` of the shape goes, in composition pixels. Elsewhere the
|
|
11
|
+
* shape is an ordinary element and CSS places it, so these are ignored.
|
|
12
|
+
*/
|
|
13
|
+
x?: number;
|
|
14
|
+
y?: number;
|
|
15
|
+
/** The point of the shape's box that `x`/`y` position. Defaults to the top-left corner. */
|
|
16
|
+
anchor?: Anchor | Point;
|
|
17
|
+
/** Degrees, clockwise. */
|
|
18
|
+
rotate?: number;
|
|
19
|
+
/** One factor, or one for each direction. */
|
|
20
|
+
scale?: number | readonly [x: number, y: number];
|
|
21
|
+
/** What `rotate` and `scale` are around. Defaults to the centre of the shape. */
|
|
22
|
+
origin?: Anchor | Point;
|
|
23
|
+
fill?: string;
|
|
24
|
+
stroke?: string;
|
|
25
|
+
strokeWidth?: number;
|
|
26
|
+
strokeLinecap?: 'butt' | 'round' | 'square';
|
|
27
|
+
strokeLinejoin?: 'miter' | 'round' | 'bevel';
|
|
28
|
+
opacity?: number;
|
|
29
|
+
/** Draw only this much of the outline, 0 to 1, as when a line draws itself. The fill is unaffected. */
|
|
30
|
+
progress?: number;
|
|
31
|
+
class?: string;
|
|
32
|
+
style?: string;
|
|
33
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|