panelui-web 0.0.0-stage → 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.
Files changed (32) hide show
  1. package/README.md +43 -2
  2. package/dist/components/bar-picker/index.d.ts +61 -0
  3. package/dist/components/bar-picker/index.js +139 -0
  4. package/dist/components/command-dock/index.d.ts +118 -0
  5. package/dist/components/command-dock/index.js +239 -0
  6. package/dist/components/day-grid/index.d.ts +87 -0
  7. package/dist/components/day-grid/index.js +251 -0
  8. package/dist/components/feedback/index.d.ts +135 -0
  9. package/dist/components/feedback/index.js +283 -0
  10. package/dist/components/flow/flow-paths.d.ts +94 -0
  11. package/dist/components/flow/flow-paths.js +270 -0
  12. package/dist/components/flow/index.d.ts +235 -0
  13. package/dist/components/flow/index.js +597 -0
  14. package/dist/components/kpi/index.d.ts +211 -0
  15. package/dist/components/kpi/index.js +243 -0
  16. package/dist/components/lane-board/index.d.ts +125 -0
  17. package/dist/components/lane-board/index.js +691 -0
  18. package/dist/components/planet/index.d.ts +132 -0
  19. package/dist/components/planet/index.js +653 -0
  20. package/dist/components/planet/land.d.ts +8 -0
  21. package/dist/components/planet/land.js +122 -0
  22. package/dist/components/range-meter/index.d.ts +70 -0
  23. package/dist/components/range-meter/index.js +170 -0
  24. package/dist/components/rolling-number/index.d.ts +46 -0
  25. package/dist/components/rolling-number/index.js +81 -0
  26. package/dist/components/tilt-card/index.d.ts +60 -0
  27. package/dist/components/tilt-card/index.js +114 -0
  28. package/dist/index.d.ts +12 -0
  29. package/dist/index.js +12 -0
  30. package/dist/lib/cn.d.ts +3 -0
  31. package/dist/lib/cn.js +6 -0
  32. package/package.json +72 -4
@@ -0,0 +1,283 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * Feedback — a dialog whose body is something to write in, and whose actions
5
+ * sit in a band around it.
6
+ *
7
+ * ```tsx
8
+ * <Feedback>
9
+ * <Feedback.Trigger>Send feedback</Feedback.Trigger>
10
+ * <Feedback.Content>
11
+ * <Feedback.Panel>
12
+ * <Feedback.Title>What should we fix first?</Feedback.Title>
13
+ * <Feedback.Close />
14
+ * <Feedback.Field placeholder="Tell us what got in your way" />
15
+ * </Feedback.Panel>
16
+ * <Feedback.Footer>
17
+ * <Feedback.Cancel />
18
+ * <Feedback.Submit onSubmit={send} />
19
+ * </Feedback.Footer>
20
+ * </Feedback.Content>
21
+ * </Feedback>
22
+ * ```
23
+ *
24
+ * ## The panel is a well
25
+ *
26
+ * A dialog that asks for a sentence has two jobs on screen at once: hold what
27
+ * is being written, and offer the two things to do with it. Here the writing
28
+ * surface is set into the dialog and the buttons sit in the band around it.
29
+ * The recess says the field is the page and the rest is its frame, before a
30
+ * word has been read. It is also why the field has no border of its own: an
31
+ * outline inside a well is two edges making one point.
32
+ *
33
+ * ## Nothing is sent empty
34
+ *
35
+ * `Submit` is inert while the field is empty. A dialog that accepts an empty
36
+ * answer produces empty feedback, from somebody who believes they said
37
+ * something. Inert, it drops its fill instead of dimming it, because a tinted
38
+ * accent pill still reads as the thing to press whatever its opacity.
39
+ *
40
+ * ## It does not close itself
41
+ *
42
+ * `Submit` hands the message to `onSubmit` and stays open. Sending has to
43
+ * finish first, and a dialog that closed on the press would take its own error
44
+ * message with it.
45
+ */
46
+ import { Dialog } from '@base-ui/react/dialog';
47
+ import { ScrollArea } from '@base-ui/react/scroll-area';
48
+ import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState, } from 'react';
49
+ import { tv } from 'tailwind-variants';
50
+ const feedbackVariants = tv({
51
+ slots: {
52
+ backdrop: 'fixed inset-0 z-50 bg-black/40 transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0',
53
+ viewport: 'fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4',
54
+ /*
55
+ * The shell is the popover surface with black mixed into it. The well it
56
+ * holds is the plain popover surface, so the shell is the darker of the
57
+ * two in a light theme and in a dark one, which a step on a grey scale
58
+ * could not promise: those run darker one way and lighter the other.
59
+ *
60
+ * The hairline ring is for a dark theme over a dimmed page, where the
61
+ * shell and what is behind it are both nearly black and the band would
62
+ * otherwise have no edge.
63
+ *
64
+ * 38 and 30 are one corner. The panel's radius is the shell's less the
65
+ * 8px between them, which is what keeps the two curves concentric.
66
+ */
67
+ shell: 'relative w-full max-w-sm rounded-[38px] bg-[color-mix(in_oklab,var(--popover),black_5%)] p-2 text-popover-foreground shadow-xl outline-none ring-1 ring-foreground/[0.06] dark:bg-[color-mix(in_oklab,var(--popover),black_14%)]',
68
+ popup: 'transition-[opacity,scale,translate] duration-200 ease-[cubic-bezier(0.2,0,0,1)] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:translate-y-2 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',
69
+ panel: 'relative flex flex-col gap-4 rounded-[30px] bg-popover p-5 shadow-xs',
70
+ title: 'pe-9 text-[22px] font-semibold leading-7',
71
+ // Drawn at 22px against the title's cap height; the `after` is the rest
72
+ // of a 44px target, which a circle that size would not fit on the line.
73
+ close: 'absolute end-5 top-[23px] inline-flex size-[22px] cursor-pointer items-center justify-center rounded-full bg-foreground/10 text-muted-foreground outline-none transition-colors after:absolute after:-inset-[11px] hover:bg-foreground/15 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring',
74
+ /*
75
+ * The field never scrolls itself. It is always as tall as what is written
76
+ * in it, and the area round it does the scrolling, which is what lets the
77
+ * bar be a drawn one: a thin rounded thumb that shows while the pointer is
78
+ * over the field or the text is moving, in place of the browser's own.
79
+ */
80
+ field: 'block w-full resize-none overflow-hidden bg-transparent p-0 text-[17px] leading-6 text-foreground outline-none placeholder:text-muted-foreground',
81
+ scroller: 'relative',
82
+ scrollerViewport: 'rounded-[inherit] outline-none data-has-overflow-y:overscroll-y-contain',
83
+ // Moved out into the well's padding, so it runs beside the text and not
84
+ // over the ends of its lines.
85
+ scrollbar: 'my-1 -me-3.5 flex w-1.5 opacity-0 transition-opacity delay-300 data-hovering:opacity-100 data-hovering:delay-0 data-hovering:duration-100 data-scrolling:opacity-100 data-scrolling:delay-0 data-scrolling:duration-100',
86
+ thumb: 'relative flex-1 rounded-full bg-foreground/20',
87
+ // Held in from the shell's edge further than the panel is. A row the full
88
+ // width would read as a third edge of the dialog, not as two buttons.
89
+ footer: 'flex items-center gap-3.5 px-[18px] pb-2.5 pt-4',
90
+ action: 'inline-flex h-11 flex-1 cursor-pointer items-center justify-center rounded-full text-base outline-none transition-[scale,background-color] duration-150 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-[0.97] motion-reduce:transition-none',
91
+ },
92
+ variants: {
93
+ blur: {
94
+ true: { backdrop: 'bg-black/20 backdrop-blur-md' },
95
+ },
96
+ tone: {
97
+ /** The one that discards. A tint of the foreground, not a fill. */
98
+ cancel: { action: 'bg-foreground/10 font-medium text-muted-foreground hover:bg-foreground/15' },
99
+ /** The one that sends. Filled, and the only filled thing on the dialog. */
100
+ submit: { action: 'bg-primary font-semibold text-primary-foreground hover:bg-primary/90' },
101
+ },
102
+ disabled: {
103
+ true: { action: 'cursor-not-allowed active:scale-100' },
104
+ },
105
+ },
106
+ compoundVariants: [
107
+ // Fainter than Cancel's own tint on purpose. Matched, they would read as
108
+ // two Cancels, not as one live action and one that is not ready.
109
+ {
110
+ tone: 'submit',
111
+ disabled: true,
112
+ class: {
113
+ action: 'bg-foreground/[0.04] font-medium text-muted-foreground/50 hover:bg-foreground/[0.04]',
114
+ },
115
+ },
116
+ { tone: 'cancel', disabled: true, class: { action: 'opacity-45' } },
117
+ ],
118
+ });
119
+ const FeedbackContext = createContext(null);
120
+ function useFeedback(part) {
121
+ const context = useContext(FeedbackContext);
122
+ if (!context)
123
+ throw new Error(`${part} must be used inside a <Feedback>.`);
124
+ return context;
125
+ }
126
+ function FeedbackRoot({ children, open, onOpenChange, defaultOpen = false, value, defaultValue = '', onValueChange, dismissible = true, }) {
127
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
128
+ const resolvedOpen = open ?? internalOpen;
129
+ const [internalValue, setInternalValue] = useState(defaultValue);
130
+ const resolvedValue = value ?? internalValue;
131
+ const submit = useRef(null);
132
+ const setOpen = useCallback((next) => {
133
+ if (open === undefined)
134
+ setInternalOpen(next);
135
+ onOpenChange?.(next);
136
+ }, [open, onOpenChange]);
137
+ const setValue = useCallback((next) => {
138
+ if (value === undefined)
139
+ setInternalValue(next);
140
+ onValueChange?.(next);
141
+ }, [value, onValueChange]);
142
+ const context = useMemo(() => ({ open: resolvedOpen, setOpen, value: resolvedValue, setValue, submit, inline: false }), [resolvedOpen, setOpen, resolvedValue, setValue]);
143
+ return (_jsx(FeedbackContext.Provider, { value: context, children: _jsx(Dialog.Root, { open: resolvedOpen, disablePointerDismissal: !dismissible, onOpenChange: (next, details) => {
144
+ // The primitive has a switch for a press outside and none for
145
+ // Escape, so that one is refused here.
146
+ if (!next && !dismissible && details.reason === 'escape-key')
147
+ return;
148
+ setOpen(next);
149
+ }, children: children }) }));
150
+ }
151
+ FeedbackRoot.displayName = 'Feedback';
152
+ /** What opens the dialog. A plain button unless `render` supplies one. */
153
+ function FeedbackTrigger({ render, children, ...props }) {
154
+ return (_jsx(Dialog.Trigger, { render: render, ...props, children: children }));
155
+ }
156
+ FeedbackTrigger.displayName = 'Feedback.Trigger';
157
+ /** The shell: the darker band, and everything set in it. */
158
+ function FeedbackContent({ inline = false, blur = false, className, children, ...props }) {
159
+ const feedback = useFeedback('Feedback.Content');
160
+ const slots = feedbackVariants({ blur });
161
+ const context = useMemo(() => ({ ...feedback, inline }), [feedback, inline]);
162
+ if (inline) {
163
+ return (_jsx(FeedbackContext.Provider, { value: context, children: _jsx("div", { className: slots.shell({ className }), ...props, children: children }) }));
164
+ }
165
+ return (_jsxs(Dialog.Portal, { children: [_jsx(Dialog.Backdrop, { className: slots.backdrop() }), _jsx(Dialog.Viewport, { className: slots.viewport(), children: _jsx(Dialog.Popup, { className: slots.shell({ className: [slots.popup(), className] }), ...props, children: _jsx(FeedbackContext.Provider, { value: context, children: children }) }) })] }));
166
+ }
167
+ FeedbackContent.displayName = 'Feedback.Content';
168
+ /** The well set into the shell: the title, the close button and the field. */
169
+ function FeedbackPanel({ className, ...props }) {
170
+ const slots = feedbackVariants();
171
+ return _jsx("div", { className: slots.panel({ className }), ...props });
172
+ }
173
+ FeedbackPanel.displayName = 'Feedback.Panel';
174
+ /** The question. In a dialog it is also what the dialog is announced as. */
175
+ function FeedbackTitle({ className, ...props }) {
176
+ const { inline } = useFeedback('Feedback.Title');
177
+ const slots = feedbackVariants();
178
+ if (inline)
179
+ return _jsx("h2", { className: slots.title({ className }), ...props });
180
+ return _jsx(Dialog.Title, { className: slots.title({ className }), ...props });
181
+ }
182
+ FeedbackTitle.displayName = 'Feedback.Title';
183
+ /** The ✕ in the panel's corner. */
184
+ function FeedbackClose({ label = 'Close', className, onClick, ...props }) {
185
+ const { setOpen } = useFeedback('Feedback.Close');
186
+ const slots = feedbackVariants();
187
+ return (_jsx("button", { type: "button", "aria-label": label, className: slots.close({ className }), onClick: (event) => {
188
+ onClick?.(event);
189
+ if (!event.defaultPrevented)
190
+ setOpen(false);
191
+ }, ...props, children: _jsx("svg", { viewBox: "0 0 10 10", "aria-hidden": "true", className: "size-2.5", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", children: _jsx("path", { d: "M2 2l6 6M8 2l-6 6" }) }) }));
192
+ }
193
+ FeedbackClose.displayName = 'Feedback.Close';
194
+ /**
195
+ * What is being written. No border and no background of its own: it is
196
+ * already inside a well.
197
+ */
198
+ function FeedbackField({ value, onValueChange, minHeight = 200, maxHeight = 360, className, style, onKeyDown, ref, 'aria-label': ariaLabel = 'Your message', ...props }) {
199
+ const feedback = useFeedback('Feedback.Field');
200
+ const slots = feedbackVariants();
201
+ const field = useRef(null);
202
+ const text = value ?? feedback.value;
203
+ // As tall as what is written, and never shorter than the room it starts
204
+ // with. Measured with the height let go first, or a field that has grown
205
+ // could never shrink.
206
+ useLayoutEffect(() => {
207
+ const node = field.current;
208
+ if (!node)
209
+ return;
210
+ node.style.height = 'auto';
211
+ node.style.height = `${Math.max(minHeight, node.scrollHeight)}px`;
212
+ }, [text, minHeight]);
213
+ const handleKeyDown = (event) => {
214
+ onKeyDown?.(event);
215
+ if (event.defaultPrevented)
216
+ return;
217
+ // Enter makes a new line, as it does in any text box. Command or Control
218
+ // with it sends, which is the shortcut for it everywhere else.
219
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
220
+ event.preventDefault();
221
+ feedback.submit.current?.();
222
+ }
223
+ };
224
+ return (_jsxs(ScrollArea.Root, { className: slots.scroller(), children: [_jsx(ScrollArea.Viewport, { className: slots.scrollerViewport(), style: { maxHeight }, children: _jsx(ScrollArea.Content, { style: { minWidth: 0 }, children: _jsx("textarea", { ref: (node) => {
225
+ field.current = node;
226
+ if (typeof ref === 'function')
227
+ ref(node);
228
+ else if (ref)
229
+ ref.current = node;
230
+ }, "aria-label": ariaLabel, value: text, onChange: (event) => {
231
+ feedback.setValue(event.target.value);
232
+ onValueChange?.(event.target.value);
233
+ }, onKeyDown: handleKeyDown, className: slots.field({ className }), style: { minHeight, ...style }, ...props }) }) }), _jsx(ScrollArea.Scrollbar, { orientation: "vertical", className: slots.scrollbar(), children: _jsx(ScrollArea.Thumb, { className: slots.thumb() }) })] }));
234
+ }
235
+ FeedbackField.displayName = 'Feedback.Field';
236
+ /** The action row, in the band under the panel. */
237
+ function FeedbackFooter({ className, ...props }) {
238
+ const slots = feedbackVariants();
239
+ return _jsx("div", { className: slots.footer({ className }), ...props });
240
+ }
241
+ FeedbackFooter.displayName = 'Feedback.Footer';
242
+ /** Discards and closes. Call `preventDefault` in `onClick` to keep it open. */
243
+ function FeedbackCancel({ children = 'Cancel', className, disabled, onClick, ...props }) {
244
+ const { setOpen } = useFeedback('Feedback.Cancel');
245
+ const slots = feedbackVariants({ tone: 'cancel', disabled: !!disabled });
246
+ return (_jsx("button", { type: "button", disabled: disabled, className: slots.action({ className }), onClick: (event) => {
247
+ onClick?.(event);
248
+ if (!event.defaultPrevented)
249
+ setOpen(false);
250
+ }, ...props, children: children }));
251
+ }
252
+ FeedbackCancel.displayName = 'Feedback.Cancel';
253
+ /** Sends. Inert while the field is empty, unless `disabled` says otherwise. */
254
+ function FeedbackSubmit({ children = 'Submit', className, disabled, onClick, onSubmit, ...props }) {
255
+ const feedback = useFeedback('Feedback.Submit');
256
+ // Empty feedback is worse than none: it is sent by somebody who believes
257
+ // they said something.
258
+ const inert = disabled ?? feedback.value.trim().length === 0;
259
+ const slots = feedbackVariants({ tone: 'submit', disabled: inert });
260
+ // What the field's shortcut calls. Kept current on every render, so it
261
+ // sends what is in the field now and not what was there when it mounted.
262
+ feedback.submit.current = inert ? null : () => onSubmit?.(feedback.value);
263
+ return (_jsx("button", { type: "button", disabled: inert, className: slots.action({ className }), onClick: (event) => {
264
+ onClick?.(event);
265
+ if (!event.defaultPrevented)
266
+ onSubmit?.(feedback.value);
267
+ }, ...props, children: children }));
268
+ }
269
+ FeedbackSubmit.displayName = 'Feedback.Submit';
270
+ export const Feedback = Object.assign(FeedbackRoot, {
271
+ Trigger: FeedbackTrigger,
272
+ Content: FeedbackContent,
273
+ Panel: FeedbackPanel,
274
+ Title: FeedbackTitle,
275
+ Close: FeedbackClose,
276
+ Field: FeedbackField,
277
+ Footer: FeedbackFooter,
278
+ Cancel: FeedbackCancel,
279
+ Submit: FeedbackSubmit,
280
+ });
281
+ /* The parts by name, for a Server Component, which cannot dot into a client
282
+ module. `<FeedbackContent>` there is `<Feedback.Content>` everywhere else. */
283
+ export { FeedbackTrigger, FeedbackContent, FeedbackPanel, FeedbackTitle, FeedbackClose, FeedbackField, FeedbackFooter, FeedbackCancel, FeedbackSubmit, };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Edge geometry for Flow.
3
+ *
4
+ * Everything here is plain arithmetic on plain numbers: where on a node's face
5
+ * an edge attaches, which faces two nodes should use when nobody has said, and
6
+ * the path between two anchors in each of the four routings. It knows nothing
7
+ * about React or the page, which is what lets it be tested by itself.
8
+ *
9
+ * Kept out of `index.tsx` so the component file stays about components. Named
10
+ * `flow-paths` and not `paths`, because a copied component lands beside others
11
+ * and `paths.ts` would be a land grab on a very common name.
12
+ */
13
+ /** Which face of a node an edge leaves from or arrives at. */
14
+ export type FlowSide = 'top' | 'right' | 'bottom' | 'left';
15
+ /** A node's box in graph coordinates. */
16
+ export interface FlowRect {
17
+ x: number;
18
+ y: number;
19
+ width: number;
20
+ height: number;
21
+ }
22
+ export interface FlowPoint {
23
+ x: number;
24
+ y: number;
25
+ }
26
+ /** The outward unit normal of a face — which way an edge sets off. */
27
+ export declare function sideNormal(side: FlowSide): FlowPoint;
28
+ /**
29
+ * Where on a face an edge attaches. `offset` runs 0–1 along the face, so two
30
+ * handles on the same side can sit apart without either knowing about the other.
31
+ */
32
+ export declare function anchorOf(rect: FlowRect, side: FlowSide, offset: number): FlowPoint;
33
+ /**
34
+ * Pushes an anchor out along its own face's normal.
35
+ *
36
+ * For an end attached to something that draws its own border: landing exactly
37
+ * on the border puts the line under the stroke, where it reads as stopping
38
+ * short rather than as arriving.
39
+ */
40
+ export declare function standOff(point: FlowPoint, side: FlowSide, by: number): FlowPoint;
41
+ /**
42
+ * The faces two nodes should use when nobody has said. Whichever axis they are
43
+ * further apart on wins, so nodes side by side connect left-to-right and nodes
44
+ * stacked connect top-to-bottom — and the edge changes its mind as they are
45
+ * dragged past each other, which is what makes a hand-arranged graph stay
46
+ * readable without anyone re-specifying anything.
47
+ */
48
+ export declare function autoSides(from: FlowRect, to: FlowRect): {
49
+ from: FlowSide;
50
+ to: FlowSide;
51
+ };
52
+ /** A line, for a graph where the routing is not the point. */
53
+ export declare function straightPath(from: FlowPoint, to: FlowPoint): string;
54
+ /** A cubic curve leaving and arriving perpendicular to each face. */
55
+ export declare function bezierPath(from: FlowPoint, fromSide: FlowSide, to: FlowPoint, toSide: FlowSide, curvature: number): string;
56
+ /**
57
+ * An orthogonal route with rounded corners — the one that reads as wiring.
58
+ *
59
+ * Both ends first step `gap` points straight out along their own face, so the
60
+ * edge leaves the node square-on instead of grazing its corner. From there the
61
+ * two are joined by a single dog-leg, split across the axis they are further
62
+ * apart on.
63
+ *
64
+ * `radius` of 0 gives hard corners, which is the stepped variant.
65
+ */
66
+ export declare function smoothStepPath(from: FlowPoint, fromSide: FlowSide, to: FlowPoint, toSide: FlowSide, radius: number, gap: number): string;
67
+ /**
68
+ * The `d` string for an edge between two anchors, in whichever shape was asked
69
+ * for. One entry point, so an edge's path is a single call.
70
+ */
71
+ export declare function edgePath(variant: 'bezier' | 'smoothstep' | 'step' | 'straight', from: FlowPoint, fromSide: FlowSide, to: FlowPoint, toSide: FlowSide, curvature: number, radius: number, gap: number): string;
72
+ /** Length of an edge's bounding diagonal — enough to size a dash cycle by. */
73
+ export declare function edgeSpan(from: FlowPoint, to: FlowPoint): number;
74
+ /**
75
+ * The arrowhead at an edge's target, as its own closed path.
76
+ *
77
+ * SVG has `marker-end` for exactly this, and it is the obvious way to do it.
78
+ * It is not used, because a path carrying a marker reference stops picking up
79
+ * new geometry on re-render — the arrow-bearing edges freeze where they were
80
+ * first drawn while arrow-less ones follow their nodes. Drawing the triangle
81
+ * as an ordinary path costs nine numbers and behaves.
82
+ *
83
+ * `dirX`/`dirY` is the unit direction of travel *into* the target.
84
+ */
85
+ export declare function arrowHeadPath(tip: FlowPoint, dirX: number, dirY: number, size: number): string;
86
+ /**
87
+ * Which way an edge is travelling as it arrives. Every routing but `straight`
88
+ * comes in perpendicular to the target's face, so the face decides it; a
89
+ * straight line comes in along itself.
90
+ */
91
+ export declare function arrivalDirection(variant: 'bezier' | 'smoothstep' | 'step' | 'straight', from: FlowPoint, to: FlowPoint, toSide: FlowSide): {
92
+ x: number;
93
+ y: number;
94
+ };
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Edge geometry for Flow.
3
+ *
4
+ * Everything here is plain arithmetic on plain numbers: where on a node's face
5
+ * an edge attaches, which faces two nodes should use when nobody has said, and
6
+ * the path between two anchors in each of the four routings. It knows nothing
7
+ * about React or the page, which is what lets it be tested by itself.
8
+ *
9
+ * Kept out of `index.tsx` so the component file stays about components. Named
10
+ * `flow-paths` and not `paths`, because a copied component lands beside others
11
+ * and `paths.ts` would be a land grab on a very common name.
12
+ */
13
+ /** The outward unit normal of a face — which way an edge sets off. */
14
+ export function sideNormal(side) {
15
+ if (side === 'left')
16
+ return { x: -1, y: 0 };
17
+ if (side === 'right')
18
+ return { x: 1, y: 0 };
19
+ if (side === 'top')
20
+ return { x: 0, y: -1 };
21
+ return { x: 0, y: 1 };
22
+ }
23
+ /**
24
+ * Where on a face an edge attaches. `offset` runs 0–1 along the face, so two
25
+ * handles on the same side can sit apart without either knowing about the other.
26
+ */
27
+ export function anchorOf(rect, side, offset) {
28
+ if (side === 'top') {
29
+ return { x: rect.x + rect.width * offset, y: rect.y };
30
+ }
31
+ if (side === 'bottom') {
32
+ return { x: rect.x + rect.width * offset, y: rect.y + rect.height };
33
+ }
34
+ if (side === 'left') {
35
+ return { x: rect.x, y: rect.y + rect.height * offset };
36
+ }
37
+ return { x: rect.x + rect.width, y: rect.y + rect.height * offset };
38
+ }
39
+ /**
40
+ * Pushes an anchor out along its own face's normal.
41
+ *
42
+ * For an end attached to something that draws its own border: landing exactly
43
+ * on the border puts the line under the stroke, where it reads as stopping
44
+ * short rather than as arriving.
45
+ */
46
+ export function standOff(point, side, by) {
47
+ if (by === 0)
48
+ return point;
49
+ const normal = sideNormal(side);
50
+ return { x: point.x + normal.x * by, y: point.y + normal.y * by };
51
+ }
52
+ /**
53
+ * The faces two nodes should use when nobody has said. Whichever axis they are
54
+ * further apart on wins, so nodes side by side connect left-to-right and nodes
55
+ * stacked connect top-to-bottom — and the edge changes its mind as they are
56
+ * dragged past each other, which is what makes a hand-arranged graph stay
57
+ * readable without anyone re-specifying anything.
58
+ */
59
+ export function autoSides(from, to) {
60
+ const dx = to.x + to.width / 2 - (from.x + from.width / 2);
61
+ const dy = to.y + to.height / 2 - (from.y + from.height / 2);
62
+ if (Math.abs(dx) > Math.abs(dy)) {
63
+ return dx >= 0 ? { from: 'right', to: 'left' } : { from: 'left', to: 'right' };
64
+ }
65
+ return dy >= 0 ? { from: 'bottom', to: 'top' } : { from: 'top', to: 'bottom' };
66
+ }
67
+ /** A line, for a graph where the routing is not the point. */
68
+ export function straightPath(from, to) {
69
+ return `M${from.x},${from.y} L${to.x},${to.y}`;
70
+ }
71
+ /**
72
+ * How far a bezier's control point sits from its anchor.
73
+ *
74
+ * With room between the two faces it is half the gap, which gives the familiar
75
+ * S. When they overlap — the target is *behind* the source — the gap is
76
+ * negative and half of it would fold the curve inside out, so the offset grows
77
+ * with the square root of the overlap instead: enough to bulge the curve clear
78
+ * of the node, never enough to run away as the overlap grows.
79
+ */
80
+ function controlOffset(distance, curvature) {
81
+ if (distance >= 0)
82
+ return 0.5 * distance;
83
+ return curvature * 25 * Math.sqrt(-distance);
84
+ }
85
+ function controlPoint(point, side, other, curvature) {
86
+ if (side === 'left') {
87
+ return { x: point.x - controlOffset(point.x - other.x, curvature), y: point.y };
88
+ }
89
+ if (side === 'right') {
90
+ return { x: point.x + controlOffset(other.x - point.x, curvature), y: point.y };
91
+ }
92
+ if (side === 'top') {
93
+ return { x: point.x, y: point.y - controlOffset(point.y - other.y, curvature) };
94
+ }
95
+ return { x: point.x, y: point.y + controlOffset(other.y - point.y, curvature) };
96
+ }
97
+ /** A cubic curve leaving and arriving perpendicular to each face. */
98
+ export function bezierPath(from, fromSide, to, toSide, curvature) {
99
+ const c1 = controlPoint(from, fromSide, to, curvature);
100
+ const c2 = controlPoint(to, toSide, from, curvature);
101
+ return `M${from.x},${from.y} C${c1.x},${c1.y} ${c2.x},${c2.y} ${to.x},${to.y}`;
102
+ }
103
+ function distanceBetween(a, b) {
104
+ const dx = b.x - a.x;
105
+ const dy = b.y - a.y;
106
+ return Math.sqrt(dx * dx + dy * dy);
107
+ }
108
+ /**
109
+ * One corner of a stepped path, rounded.
110
+ *
111
+ * The radius is capped at half of the shorter of the two segments meeting here,
112
+ * so a corner between two short runs tucks in rather than overshooting into the
113
+ * segment beyond it and drawing a path that doubles back.
114
+ */
115
+ function bend(a, b, c, radius) {
116
+ const size = Math.min(distanceBetween(a, b) / 2, distanceBetween(b, c) / 2, radius);
117
+ // Three collinear points are not a corner.
118
+ if ((a.x === b.x && b.x === c.x) || (a.y === b.y && b.y === c.y)) {
119
+ return `L${b.x},${b.y}`;
120
+ }
121
+ if (a.y === b.y) {
122
+ const xDir = a.x < c.x ? -1 : 1;
123
+ const yDir = a.y < c.y ? 1 : -1;
124
+ return `L${b.x + size * xDir},${b.y}Q${b.x},${b.y} ${b.x},${b.y + size * yDir}`;
125
+ }
126
+ const xDir = a.x < c.x ? 1 : -1;
127
+ const yDir = a.y < c.y ? -1 : 1;
128
+ return `L${b.x},${b.y + size * yDir}Q${b.x},${b.y} ${b.x + size * xDir},${b.y}`;
129
+ }
130
+ /**
131
+ * An orthogonal route with rounded corners — the one that reads as wiring.
132
+ *
133
+ * Both ends first step `gap` points straight out along their own face, so the
134
+ * edge leaves the node square-on instead of grazing its corner. From there the
135
+ * two are joined by a single dog-leg, split across the axis they are further
136
+ * apart on.
137
+ *
138
+ * `radius` of 0 gives hard corners, which is the stepped variant.
139
+ */
140
+ export function smoothStepPath(from, fromSide, to, toSide, radius, gap) {
141
+ const fromNormal = sideNormal(fromSide);
142
+ const toNormal = sideNormal(toSide);
143
+ let fromGap = {
144
+ x: from.x + fromNormal.x * gap,
145
+ y: from.y + fromNormal.y * gap,
146
+ };
147
+ let toGap = { x: to.x + toNormal.x * gap, y: to.y + toNormal.y * gap };
148
+ // The axis the two gap points are further apart on is the one the dog-leg
149
+ // travels along.
150
+ const horizontal = Math.abs(toGap.x - fromGap.x) > Math.abs(toGap.y - fromGap.y);
151
+ const facing = (fromNormal.x !== 0 && toNormal.x !== 0 && fromNormal.x !== toNormal.x) ||
152
+ (fromNormal.y !== 0 && toNormal.y !== 0 && fromNormal.y !== toNormal.y);
153
+ let middle;
154
+ if (facing) {
155
+ // Faces pointing at each other: meet in the middle of the run between them.
156
+ const midX = (fromGap.x + toGap.x) / 2;
157
+ const midY = (fromGap.y + toGap.y) / 2;
158
+ middle =
159
+ fromNormal.x !== 0
160
+ ? [
161
+ { x: midX, y: fromGap.y },
162
+ { x: midX, y: toGap.y },
163
+ ]
164
+ : [
165
+ { x: fromGap.x, y: midY },
166
+ { x: toGap.x, y: midY },
167
+ ];
168
+ }
169
+ else {
170
+ // Faces pointing the same way, or at right angles: one corner is enough.
171
+ // Which of the two possible corners depends on the travelling axis.
172
+ middle = horizontal ? [{ x: toGap.x, y: fromGap.y }] : [{ x: fromGap.x, y: toGap.y }];
173
+ // Two handles on the same face, closer together than the gap: the gap
174
+ // points sit between the anchors and the route folds back over itself.
175
+ // Push them out far enough to clear.
176
+ if (fromSide === toSide) {
177
+ const axis = fromNormal.x !== 0 ? 'x' : 'y';
178
+ const apart = Math.abs(from[axis] - to[axis]);
179
+ if (apart <= gap) {
180
+ const push = Math.min(gap - 1, gap - apart);
181
+ if (axis === 'x') {
182
+ fromGap = { x: fromGap.x + fromNormal.x * push, y: fromGap.y };
183
+ toGap = { x: toGap.x + toNormal.x * push, y: toGap.y };
184
+ middle = [{ x: Math.max(fromGap.x, toGap.x), y: fromGap.y }];
185
+ }
186
+ else {
187
+ fromGap = { x: fromGap.x, y: fromGap.y + fromNormal.y * push };
188
+ toGap = { x: toGap.x, y: toGap.y + toNormal.y * push };
189
+ middle = [{ x: fromGap.x, y: Math.max(fromGap.y, toGap.y) }];
190
+ }
191
+ }
192
+ }
193
+ }
194
+ const points = [from, fromGap, ...middle, toGap, to];
195
+ // Drop points that repeat the one before them — a duplicate reads as a
196
+ // zero-length segment and makes the bend either side of it collapse.
197
+ const route = [];
198
+ for (let i = 0; i < points.length; i += 1) {
199
+ const point = points[i];
200
+ const previous = route[route.length - 1];
201
+ if (previous && previous.x === point.x && previous.y === point.y)
202
+ continue;
203
+ route.push(point);
204
+ }
205
+ if (route.length < 2)
206
+ return `M${from.x},${from.y}`;
207
+ let d = `M${route[0].x},${route[0].y}`;
208
+ for (let i = 1; i < route.length - 1; i += 1) {
209
+ d +=
210
+ radius > 0
211
+ ? bend(route[i - 1], route[i], route[i + 1], radius)
212
+ : `L${route[i].x},${route[i].y}`;
213
+ }
214
+ const last = route[route.length - 1];
215
+ d += `L${last.x},${last.y}`;
216
+ return d;
217
+ }
218
+ /**
219
+ * The `d` string for an edge between two anchors, in whichever shape was asked
220
+ * for. One entry point, so an edge's path is a single call.
221
+ */
222
+ export function edgePath(variant, from, fromSide, to, toSide, curvature, radius, gap) {
223
+ if (variant === 'straight')
224
+ return straightPath(from, to);
225
+ if (variant === 'bezier')
226
+ return bezierPath(from, fromSide, to, toSide, curvature);
227
+ return smoothStepPath(from, fromSide, to, toSide, variant === 'step' ? 0 : radius, gap);
228
+ }
229
+ /** Length of an edge's bounding diagonal — enough to size a dash cycle by. */
230
+ export function edgeSpan(from, to) {
231
+ return distanceBetween(from, to);
232
+ }
233
+ /**
234
+ * The arrowhead at an edge's target, as its own closed path.
235
+ *
236
+ * SVG has `marker-end` for exactly this, and it is the obvious way to do it.
237
+ * It is not used, because a path carrying a marker reference stops picking up
238
+ * new geometry on re-render — the arrow-bearing edges freeze where they were
239
+ * first drawn while arrow-less ones follow their nodes. Drawing the triangle
240
+ * as an ordinary path costs nine numbers and behaves.
241
+ *
242
+ * `dirX`/`dirY` is the unit direction of travel *into* the target.
243
+ */
244
+ export function arrowHeadPath(tip, dirX, dirY, size) {
245
+ const length = Math.sqrt(dirX * dirX + dirY * dirY) || 1;
246
+ const ux = dirX / length;
247
+ const uy = dirY / length;
248
+ // The perpendicular, for the two back corners.
249
+ const px = -uy;
250
+ const py = ux;
251
+ const baseX = tip.x - ux * size;
252
+ const baseY = tip.y - uy * size;
253
+ const half = size * 0.45;
254
+ return (`M${tip.x},${tip.y} ` +
255
+ `L${baseX + px * half},${baseY + py * half} ` +
256
+ `L${baseX - px * half},${baseY - py * half} Z`);
257
+ }
258
+ /**
259
+ * Which way an edge is travelling as it arrives. Every routing but `straight`
260
+ * comes in perpendicular to the target's face, so the face decides it; a
261
+ * straight line comes in along itself.
262
+ */
263
+ export function arrivalDirection(variant, from, to, toSide) {
264
+ if (variant === 'straight') {
265
+ return { x: to.x - from.x, y: to.y - from.y };
266
+ }
267
+ const normal = sideNormal(toSide);
268
+ // The face points outward; the edge arrives against it.
269
+ return { x: -normal.x, y: -normal.y };
270
+ }