@pswaqtch/around 0.1.0 → 0.1.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/README.md +86 -0
- package/dist/index.css +1 -1
- package/dist/index.js +285 -263
- package/package.json +6 -3
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @pswaqtch/around
|
|
2
|
+
|
|
3
|
+
Radial text layout engine for React. Wraps article-formatted text around closed and open track shapes — stadium, ellipse, spiral, wave, blob, or a custom SVG path.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @pswaqtch/around
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { RadialText } from "@pswaqtch/around";
|
|
15
|
+
import "@pswaqtch/around/style.css";
|
|
16
|
+
|
|
17
|
+
function App() {
|
|
18
|
+
return (
|
|
19
|
+
<RadialText
|
|
20
|
+
text={articleText}
|
|
21
|
+
shape="stadium"
|
|
22
|
+
loop
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The component renders a fixed, full-viewport disc. Scrolling advances the text along the track.
|
|
29
|
+
|
|
30
|
+
## Props
|
|
31
|
+
|
|
32
|
+
| Prop | Type | Default | Description |
|
|
33
|
+
|------|------|---------|-------------|
|
|
34
|
+
| `text` | `string` | — | Markdown-lite article text (headings `#`, quotes `>`, lists `-`) |
|
|
35
|
+
| `shape` | `RadialShapeKind` | `"stadium"` | Track shape |
|
|
36
|
+
| `loop` | `boolean` | `false` | Loop text continuously around closed tracks |
|
|
37
|
+
| `geometry` | `RadialTextGeometry` | — | Shape dimensions (width/height ratios, corner radius, etc.) |
|
|
38
|
+
| `layout` | `RadialTextLayout` | — | Text inset, line spacing, alignment |
|
|
39
|
+
| `typography` | `RadialTextTypography` | — | Font family, sizes, weights |
|
|
40
|
+
| `discBg` | `string` | — | CSS `background` for the disc (overrides default gradient) |
|
|
41
|
+
| `textColor` | `string` | `#171717` | Base text color |
|
|
42
|
+
| `showGuides` | `boolean` | `true` | Show inner/outer track boundary guides |
|
|
43
|
+
| `ref` | `Ref<RadialTextHandle>` | — | Imperative handle for export |
|
|
44
|
+
|
|
45
|
+
### `RadialShapeKind`
|
|
46
|
+
|
|
47
|
+
`"stadium" | "ellipse" | "spiral" | "wave" | "blob" | "svg-path"`
|
|
48
|
+
|
|
49
|
+
### `RadialTextHandle`
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
interface RadialTextHandle {
|
|
53
|
+
discEl: HTMLDivElement; // the viewport-filling disc element
|
|
54
|
+
rootEl: HTMLDivElement; // the component root
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Export
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { exportDiscAsPng, exportDiscAsSvg } from "@pswaqtch/around";
|
|
62
|
+
|
|
63
|
+
const { discEl, rootEl } = radialRef.current;
|
|
64
|
+
await exportDiscAsPng(discEl, rootEl, "output.png");
|
|
65
|
+
exportDiscAsSvg(discEl, "output.svg");
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`exportDiscAsSvg` walks the live DOM and emits pixel-perfect `<text>` elements — no canvas rendering.
|
|
69
|
+
|
|
70
|
+
## Text format
|
|
71
|
+
|
|
72
|
+
The `text` prop accepts a lightweight markdown-like format:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
# Heading
|
|
76
|
+
|
|
77
|
+
Regular paragraph text that wraps automatically.
|
|
78
|
+
|
|
79
|
+
> A blockquote line
|
|
80
|
+
|
|
81
|
+
- A list item
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
package/dist/index.css
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,21 +1,7 @@
|
|
|
1
|
-
// src/
|
|
1
|
+
// src/RadialText/RadialText.tsx
|
|
2
2
|
import { useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
|
|
3
3
|
|
|
4
|
-
// src/
|
|
5
|
-
var DEFAULT_BODY_STYLE = Object.freeze({
|
|
6
|
-
fontSize: 9,
|
|
7
|
-
weight: 400,
|
|
8
|
-
family: "Georgia, serif"
|
|
9
|
-
});
|
|
10
|
-
var STYLE_BY_KIND = Object.freeze({
|
|
11
|
-
heading: Object.freeze({ fontSize: 11, weight: 700, family: "Georgia, serif" }),
|
|
12
|
-
paragraph: DEFAULT_BODY_STYLE,
|
|
13
|
-
"list-item": DEFAULT_BODY_STYLE,
|
|
14
|
-
"list-continuation": DEFAULT_BODY_STYLE,
|
|
15
|
-
quote: Object.freeze({ fontSize: 9, weight: 400, family: "Georgia, serif", italic: true }),
|
|
16
|
-
spacer: DEFAULT_BODY_STYLE,
|
|
17
|
-
separator: DEFAULT_BODY_STYLE
|
|
18
|
-
});
|
|
4
|
+
// src/layout/parse.ts
|
|
19
5
|
var HEADING_MARKER_RE = /^(#{1,6})\s+(.+)$/;
|
|
20
6
|
var LIST_MARKER_RE = /^[-*+]\s+(.+)$/;
|
|
21
7
|
var NUMBERED_MARKER_RE = /^\d+[.)]\s+(.+)$/;
|
|
@@ -93,6 +79,73 @@ function parseArticle(source) {
|
|
|
93
79
|
flushParagraph();
|
|
94
80
|
return blocks;
|
|
95
81
|
}
|
|
82
|
+
function normalizeInlineText(text) {
|
|
83
|
+
return text.replace(/`([^`]+)`/g, "$1").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/\s+/g, " ").trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/layout/wrap.ts
|
|
87
|
+
import { layoutWithLines, prepareWithSegments } from "@chenglou/pretext";
|
|
88
|
+
var LINE_HEIGHT_RATIO = 1.12;
|
|
89
|
+
var measureContext;
|
|
90
|
+
function measureTextWidth(text, style) {
|
|
91
|
+
const context = getMeasureContext();
|
|
92
|
+
if (!context) {
|
|
93
|
+
return text.length * style.fontSize * 0.55;
|
|
94
|
+
}
|
|
95
|
+
context.font = toCanvasFont(style);
|
|
96
|
+
return context.measureText(text).width;
|
|
97
|
+
}
|
|
98
|
+
function fillLine(char, maxWidth, style) {
|
|
99
|
+
const charWidth = measureTextWidth(char, style);
|
|
100
|
+
const repeatCount = charWidth > 0 ? Math.floor(maxWidth / charWidth) : 1;
|
|
101
|
+
return char.repeat(Math.max(1, repeatCount));
|
|
102
|
+
}
|
|
103
|
+
function createPretextWrapper() {
|
|
104
|
+
const preparedCache = /* @__PURE__ */ new Map();
|
|
105
|
+
return function wrapText(text, maxWidth, style) {
|
|
106
|
+
const font = toCanvasFont(style);
|
|
107
|
+
const key = `${font}
|
|
108
|
+
${text}`;
|
|
109
|
+
let prepared = preparedCache.get(key);
|
|
110
|
+
if (!prepared) {
|
|
111
|
+
prepared = prepareWithSegments(text, font);
|
|
112
|
+
preparedCache.set(key, prepared);
|
|
113
|
+
}
|
|
114
|
+
return layoutWithLines(prepared, maxWidth, style.fontSize * LINE_HEIGHT_RATIO).lines.map((line) => line.text);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function getMeasureContext() {
|
|
118
|
+
if (measureContext !== void 0) {
|
|
119
|
+
return measureContext;
|
|
120
|
+
}
|
|
121
|
+
if (typeof document === "undefined") {
|
|
122
|
+
measureContext = null;
|
|
123
|
+
return measureContext;
|
|
124
|
+
}
|
|
125
|
+
const canvas = document.createElement("canvas");
|
|
126
|
+
measureContext = canvas.getContext("2d");
|
|
127
|
+
return measureContext;
|
|
128
|
+
}
|
|
129
|
+
function toCanvasFont(style) {
|
|
130
|
+
const fontStyle = style.italic ? "italic " : "";
|
|
131
|
+
return `${fontStyle}${style.weight} ${style.fontSize}px ${style.family}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/layout/lines.ts
|
|
135
|
+
var DEFAULT_BODY_STYLE = Object.freeze({
|
|
136
|
+
fontSize: 9,
|
|
137
|
+
weight: 400,
|
|
138
|
+
family: "Georgia, serif"
|
|
139
|
+
});
|
|
140
|
+
var STYLE_BY_KIND = Object.freeze({
|
|
141
|
+
heading: Object.freeze({ fontSize: 11, weight: 700, family: "Georgia, serif" }),
|
|
142
|
+
paragraph: DEFAULT_BODY_STYLE,
|
|
143
|
+
"list-item": DEFAULT_BODY_STYLE,
|
|
144
|
+
"list-continuation": DEFAULT_BODY_STYLE,
|
|
145
|
+
quote: Object.freeze({ fontSize: 9, weight: 400, family: "Georgia, serif", italic: true }),
|
|
146
|
+
spacer: DEFAULT_BODY_STYLE,
|
|
147
|
+
separator: DEFAULT_BODY_STYLE
|
|
148
|
+
});
|
|
96
149
|
function buildArticleLines(source, options) {
|
|
97
150
|
const maxWidth = options.maxWidth;
|
|
98
151
|
const wrapText = options.wrapText;
|
|
@@ -137,9 +190,6 @@ function getStyle(kind, typography) {
|
|
|
137
190
|
...kindOverride
|
|
138
191
|
};
|
|
139
192
|
}
|
|
140
|
-
function normalizeInlineText(text) {
|
|
141
|
-
return text.replace(/`([^`]+)`/g, "$1").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/\s+/g, " ").trim();
|
|
142
|
-
}
|
|
143
193
|
function shouldInsertSpacer(previous, current) {
|
|
144
194
|
if (current.kind === "list-item" && previous.kind === "list-item") {
|
|
145
195
|
return false;
|
|
@@ -158,55 +208,105 @@ function createSpacerLine(typography) {
|
|
|
158
208
|
};
|
|
159
209
|
}
|
|
160
210
|
|
|
161
|
-
// src/
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return text.length * style.fontSize * 0.55;
|
|
211
|
+
// src/tracks/math.ts
|
|
212
|
+
function roundedRectPoint(t, arcOffsetX, arcOffsetY, radius) {
|
|
213
|
+
const perimeter = 4 * arcOffsetX + 4 * arcOffsetY + 2 * Math.PI * radius;
|
|
214
|
+
let s = positiveUnit(t) * perimeter;
|
|
215
|
+
const quarterArc = Math.PI / 2 * radius;
|
|
216
|
+
if (s < 2 * arcOffsetX) {
|
|
217
|
+
return { x: -arcOffsetX + s, y: -(arcOffsetY + radius), angleDeg: -90 };
|
|
169
218
|
}
|
|
170
|
-
|
|
171
|
-
|
|
219
|
+
s -= 2 * arcOffsetX;
|
|
220
|
+
if (s < quarterArc) {
|
|
221
|
+
const alpha2 = -90 + s / quarterArc * 90;
|
|
222
|
+
const rad2 = alpha2 * Math.PI / 180;
|
|
223
|
+
return { x: arcOffsetX + radius * Math.cos(rad2), y: -arcOffsetY + radius * Math.sin(rad2), angleDeg: alpha2 };
|
|
224
|
+
}
|
|
225
|
+
s -= quarterArc;
|
|
226
|
+
if (s < 2 * arcOffsetY) {
|
|
227
|
+
return { x: arcOffsetX + radius, y: -arcOffsetY + s, angleDeg: 0 };
|
|
228
|
+
}
|
|
229
|
+
s -= 2 * arcOffsetY;
|
|
230
|
+
if (s < quarterArc) {
|
|
231
|
+
const alpha2 = s / quarterArc * 90;
|
|
232
|
+
const rad2 = alpha2 * Math.PI / 180;
|
|
233
|
+
return { x: arcOffsetX + radius * Math.cos(rad2), y: arcOffsetY + radius * Math.sin(rad2), angleDeg: alpha2 };
|
|
234
|
+
}
|
|
235
|
+
s -= quarterArc;
|
|
236
|
+
if (s < 2 * arcOffsetX) {
|
|
237
|
+
return { x: arcOffsetX - s, y: arcOffsetY + radius, angleDeg: 90 };
|
|
238
|
+
}
|
|
239
|
+
s -= 2 * arcOffsetX;
|
|
240
|
+
if (s < quarterArc) {
|
|
241
|
+
const alpha2 = 90 + s / quarterArc * 90;
|
|
242
|
+
const rad2 = alpha2 * Math.PI / 180;
|
|
243
|
+
return { x: -arcOffsetX + radius * Math.cos(rad2), y: arcOffsetY + radius * Math.sin(rad2), angleDeg: alpha2 };
|
|
244
|
+
}
|
|
245
|
+
s -= quarterArc;
|
|
246
|
+
if (s < 2 * arcOffsetY) {
|
|
247
|
+
return { x: -(arcOffsetX + radius), y: arcOffsetY - s, angleDeg: 180 };
|
|
248
|
+
}
|
|
249
|
+
s -= 2 * arcOffsetY;
|
|
250
|
+
const alpha = 180 + s / quarterArc * 90;
|
|
251
|
+
const rad = alpha * Math.PI / 180;
|
|
252
|
+
return { x: -arcOffsetX + radius * Math.cos(rad), y: -arcOffsetY + radius * Math.sin(rad), angleDeg: alpha };
|
|
172
253
|
}
|
|
173
|
-
function
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
254
|
+
function offsetNodesByAngle(nodes, distance) {
|
|
255
|
+
return nodes.map((node) => {
|
|
256
|
+
const angle = node.angleDeg * Math.PI / 180;
|
|
257
|
+
return {
|
|
258
|
+
...node,
|
|
259
|
+
x: node.x + Math.cos(angle) * distance,
|
|
260
|
+
y: node.y + Math.sin(angle) * distance
|
|
261
|
+
};
|
|
262
|
+
});
|
|
177
263
|
}
|
|
178
|
-
function
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
preparedCache.set(key, prepared);
|
|
188
|
-
}
|
|
189
|
-
return layoutWithLines(prepared, maxWidth, style.fontSize * LINE_HEIGHT_RATIO).lines.map((line) => line.text);
|
|
190
|
-
};
|
|
264
|
+
function offsetNodesByTangent(nodes, distance) {
|
|
265
|
+
return nodes.map((node) => {
|
|
266
|
+
const angle = (node.tangentDeg + 90) * Math.PI / 180;
|
|
267
|
+
return {
|
|
268
|
+
...node,
|
|
269
|
+
x: node.x + Math.cos(angle) * distance,
|
|
270
|
+
y: node.y + Math.sin(angle) * distance
|
|
271
|
+
};
|
|
272
|
+
});
|
|
191
273
|
}
|
|
192
|
-
function
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
274
|
+
function withPolylineTangents(nodes, closed) {
|
|
275
|
+
return nodes.map((node, index) => {
|
|
276
|
+
const prev = nodes[index - 1] ?? (closed ? nodes.at(-1) : node);
|
|
277
|
+
const next = nodes[index + 1] ?? (closed ? nodes[0] : node);
|
|
278
|
+
const tangentDeg = prev && next ? angleBetween(prev.x, prev.y, next.x, next.y) : 0;
|
|
279
|
+
return {
|
|
280
|
+
...node,
|
|
281
|
+
tangentDeg,
|
|
282
|
+
angleDeg: tangentDeg + 90
|
|
283
|
+
};
|
|
284
|
+
});
|
|
203
285
|
}
|
|
204
|
-
function
|
|
205
|
-
|
|
206
|
-
|
|
286
|
+
function clamp(value, min, max) {
|
|
287
|
+
return Math.min(max, Math.max(min, value));
|
|
288
|
+
}
|
|
289
|
+
function lerp(a, b, t) {
|
|
290
|
+
return a + (b - a) * t;
|
|
291
|
+
}
|
|
292
|
+
function lerpAngle(a, b, t) {
|
|
293
|
+
const delta = (b - a + 540) % 360 - 180;
|
|
294
|
+
return a + delta * t;
|
|
295
|
+
}
|
|
296
|
+
function angleBetween(x1, y1, x2, y2) {
|
|
297
|
+
return Math.atan2(y2 - y1, x2 - x1) * 180 / Math.PI;
|
|
298
|
+
}
|
|
299
|
+
function positiveModulo(value, modulo) {
|
|
300
|
+
return (value % modulo + modulo) % modulo;
|
|
301
|
+
}
|
|
302
|
+
function positiveUnit(value) {
|
|
303
|
+
return positiveModulo(value, 1);
|
|
304
|
+
}
|
|
305
|
+
function format(value) {
|
|
306
|
+
return Number(value.toFixed(2));
|
|
207
307
|
}
|
|
208
308
|
|
|
209
|
-
// src/
|
|
309
|
+
// src/tracks/build.ts
|
|
210
310
|
var DISC_MARGIN = 4;
|
|
211
311
|
var DEFAULT_GEOMETRY = {
|
|
212
312
|
widthRatio: 0.9,
|
|
@@ -239,12 +339,110 @@ function sampleTextTrackLines(lines, track, startT = 0, minSpacing = 12, loop =
|
|
|
239
339
|
}
|
|
240
340
|
return result;
|
|
241
341
|
}
|
|
342
|
+
function buildTrack(kind, nodes, closed, fallbackLineWidth, guidePath, outerGuidePath) {
|
|
343
|
+
const measured = measureNodes(nodes, closed);
|
|
344
|
+
const length = measured.at(-1)?.distance ?? 0;
|
|
345
|
+
return {
|
|
346
|
+
kind,
|
|
347
|
+
closed,
|
|
348
|
+
length,
|
|
349
|
+
lineWidth: fallbackLineWidth,
|
|
350
|
+
guidePath,
|
|
351
|
+
outerGuidePath,
|
|
352
|
+
sampleAt(distance) {
|
|
353
|
+
if (measured.length === 0) {
|
|
354
|
+
return { x: 0, y: 0, angleDeg: 0, tangentDeg: 0, lineWidth: fallbackLineWidth };
|
|
355
|
+
}
|
|
356
|
+
const target = closed ? positiveModulo(distance, length) : clamp(distance, 0, length);
|
|
357
|
+
let hi = measured.findIndex((node) => node.distance >= target);
|
|
358
|
+
if (hi <= 0) {
|
|
359
|
+
return measured[0];
|
|
360
|
+
}
|
|
361
|
+
const prev = measured[hi - 1];
|
|
362
|
+
const next = measured[hi];
|
|
363
|
+
const span = Math.max(1e-6, next.distance - prev.distance);
|
|
364
|
+
const amount = (target - prev.distance) / span;
|
|
365
|
+
const tangentDeg = angleBetween(prev.x, prev.y, next.x, next.y);
|
|
366
|
+
return {
|
|
367
|
+
x: lerp(prev.x, next.x, amount),
|
|
368
|
+
y: lerp(prev.y, next.y, amount),
|
|
369
|
+
tangentDeg,
|
|
370
|
+
angleDeg: lerpAngle(prev.angleDeg, next.angleDeg, amount),
|
|
371
|
+
lineWidth: lerp(prev.lineWidth, next.lineWidth, amount)
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
function sampleParametric(count, closed, sample) {
|
|
377
|
+
const points = [];
|
|
378
|
+
const end = closed ? count : count - 1;
|
|
379
|
+
for (let i = 0; i <= end; i += 1) {
|
|
380
|
+
const t = i / count;
|
|
381
|
+
const point = sample(t, i);
|
|
382
|
+
points.push({ ...point, tangentDeg: 0 });
|
|
383
|
+
}
|
|
384
|
+
for (let i = 0; i < points.length; i += 1) {
|
|
385
|
+
const prev = points[Math.max(0, i - 1)];
|
|
386
|
+
const next = points[Math.min(points.length - 1, i + 1)];
|
|
387
|
+
const tangentDeg = angleBetween(prev.x, prev.y, next.x, next.y);
|
|
388
|
+
points[i].tangentDeg = tangentDeg;
|
|
389
|
+
if (!Number.isFinite(points[i].angleDeg)) {
|
|
390
|
+
points[i].angleDeg = tangentDeg - 90;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return points;
|
|
394
|
+
}
|
|
395
|
+
function measureNodes(nodes, closed) {
|
|
396
|
+
if (nodes.length === 0) {
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
399
|
+
const measured = [{ ...nodes[0], distance: 0 }];
|
|
400
|
+
let distance = 0;
|
|
401
|
+
const limit = closed ? nodes.length : nodes.length - 1;
|
|
402
|
+
for (let i = 1; i <= limit; i += 1) {
|
|
403
|
+
const prev = nodes[i - 1];
|
|
404
|
+
const next = nodes[i % nodes.length];
|
|
405
|
+
distance += Math.hypot(next.x - prev.x, next.y - prev.y);
|
|
406
|
+
measured.push({ ...next, distance });
|
|
407
|
+
}
|
|
408
|
+
return measured;
|
|
409
|
+
}
|
|
410
|
+
function pathFromNodes(nodes, closed) {
|
|
411
|
+
if (nodes.length === 0) {
|
|
412
|
+
return "";
|
|
413
|
+
}
|
|
414
|
+
const path = [`M ${format(nodes[0].x)} ${format(nodes[0].y)}`];
|
|
415
|
+
for (let i = 1; i < nodes.length; i += 1) {
|
|
416
|
+
path.push(`L ${format(nodes[i].x)} ${format(nodes[i].y)}`);
|
|
417
|
+
}
|
|
418
|
+
if (closed) {
|
|
419
|
+
path.push("Z");
|
|
420
|
+
}
|
|
421
|
+
return path.join(" ");
|
|
422
|
+
}
|
|
423
|
+
function resolveGeometry(viewportWidth, viewportHeight, options) {
|
|
424
|
+
const widthRatio = clamp(options.widthRatio ?? DEFAULT_GEOMETRY.widthRatio, 0.05, 1);
|
|
425
|
+
const heightRatio = clamp(options.heightRatio ?? DEFAULT_GEOMETRY.heightRatio, 0.05, 1);
|
|
426
|
+
const trackThickness = clamp(options.trackThickness ?? DEFAULT_GEOMETRY.trackThickness, 0.05, 0.9);
|
|
427
|
+
const outerHalfW = Math.max(1, Math.floor(viewportWidth / 2 * widthRatio) - DISC_MARGIN);
|
|
428
|
+
const outerHalfH = Math.max(1, Math.floor(viewportHeight / 2 * heightRatio) - DISC_MARGIN);
|
|
429
|
+
const trackWidth = Math.max(1, Math.round(Math.min(outerHalfW, outerHalfH) * trackThickness));
|
|
430
|
+
return {
|
|
431
|
+
outerHalfW,
|
|
432
|
+
outerHalfH,
|
|
433
|
+
trackWidth,
|
|
434
|
+
cornerRadius: clamp(options.cornerRadius ?? DEFAULT_GEOMETRY.cornerRadius, 0, 1)
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// src/tracks/stadium.ts
|
|
439
|
+
var DISC_MARGIN2 = 4;
|
|
242
440
|
function createStadiumTrack(viewportWidth, viewportHeight, options = {}) {
|
|
243
441
|
const geometry = resolveGeometry(viewportWidth, viewportHeight, options);
|
|
244
442
|
const outerHalfW = geometry.outerHalfW;
|
|
245
443
|
const outerHalfH = geometry.outerHalfH;
|
|
246
|
-
const innerHalfW = options.innerWidthRatio != null ? Math.max(1, Math.floor(viewportWidth / 2 * clamp(options.innerWidthRatio, 0.05, 1)) -
|
|
247
|
-
const innerHalfH = options.innerHeightRatio != null ? Math.max(1, Math.floor(viewportHeight / 2 * clamp(options.innerHeightRatio, 0.05, 1)) -
|
|
444
|
+
const innerHalfW = options.innerWidthRatio != null ? Math.max(1, Math.floor(viewportWidth / 2 * clamp(options.innerWidthRatio, 0.05, 1)) - DISC_MARGIN2) : Math.max(1, outerHalfW - geometry.trackWidth);
|
|
445
|
+
const innerHalfH = options.innerHeightRatio != null ? Math.max(1, Math.floor(viewportHeight / 2 * clamp(options.innerHeightRatio, 0.05, 1)) - DISC_MARGIN2) : Math.max(1, outerHalfH - geometry.trackWidth);
|
|
248
446
|
const trackWidth = Math.min(outerHalfW - innerHalfW, outerHalfH - innerHalfH);
|
|
249
447
|
const radius = Math.min(innerHalfW, innerHalfH) * geometry.cornerRadius;
|
|
250
448
|
const arcOffsetX = Math.max(0, innerHalfW - radius);
|
|
@@ -255,12 +453,15 @@ function createStadiumTrack(viewportWidth, viewportHeight, options = {}) {
|
|
|
255
453
|
}));
|
|
256
454
|
return buildTrack("stadium", nodes, true, trackWidth, pathFromNodes(nodes, true), pathFromNodes(offsetNodesByAngle(nodes, trackWidth), true));
|
|
257
455
|
}
|
|
456
|
+
|
|
457
|
+
// src/tracks/ellipse.ts
|
|
458
|
+
var DISC_MARGIN3 = 4;
|
|
258
459
|
function createEllipseTrack(viewportWidth, viewportHeight, options = {}) {
|
|
259
460
|
const geometry = resolveGeometry(viewportWidth, viewportHeight, options);
|
|
260
461
|
const outerA = geometry.outerHalfW;
|
|
261
462
|
const outerB = geometry.outerHalfH;
|
|
262
|
-
const innerA = options.innerWidthRatio != null ? Math.max(1, Math.floor(viewportWidth / 2 * clamp(options.innerWidthRatio, 0.05, 1)) -
|
|
263
|
-
const innerB = options.innerHeightRatio != null ? Math.max(1, Math.floor(viewportHeight / 2 * clamp(options.innerHeightRatio, 0.05, 1)) -
|
|
463
|
+
const innerA = options.innerWidthRatio != null ? Math.max(1, Math.floor(viewportWidth / 2 * clamp(options.innerWidthRatio, 0.05, 1)) - DISC_MARGIN3) : Math.max(1, outerA - geometry.trackWidth);
|
|
464
|
+
const innerB = options.innerHeightRatio != null ? Math.max(1, Math.floor(viewportHeight / 2 * clamp(options.innerHeightRatio, 0.05, 1)) - DISC_MARGIN3) : Math.max(1, outerB - geometry.trackWidth);
|
|
264
465
|
const trackWidth = Math.min(outerA - innerA, outerB - innerB);
|
|
265
466
|
const nodes = sampleParametric(960, true, (t) => {
|
|
266
467
|
const angle = t * Math.PI * 2 - Math.PI / 2;
|
|
@@ -282,9 +483,13 @@ function createEllipseTrack(viewportWidth, viewportHeight, options = {}) {
|
|
|
282
483
|
});
|
|
283
484
|
return buildTrack("ellipse", nodes, true, trackWidth, pathFromNodes(nodes, true), pathFromNodes(offsetNodesByAngle(nodes, trackWidth), true));
|
|
284
485
|
}
|
|
486
|
+
|
|
487
|
+
// src/tracks/spiral.ts
|
|
488
|
+
var DISC_MARGIN4 = 4;
|
|
489
|
+
var DEFAULT_WIDTH_RATIO = 0.9;
|
|
285
490
|
function createSpiralTrack(viewportWidth, viewportHeight, options = {}) {
|
|
286
|
-
const scale = clamp(options.scale ??
|
|
287
|
-
const outerRadius = Math.max(1, Math.floor(Math.min(viewportWidth, viewportHeight) / 2 * scale) -
|
|
491
|
+
const scale = clamp(options.scale ?? DEFAULT_WIDTH_RATIO, 0.05, 1);
|
|
492
|
+
const outerRadius = Math.max(1, Math.floor(Math.min(viewportWidth, viewportHeight) / 2 * scale) - DISC_MARGIN4);
|
|
288
493
|
const trackThickness = clamp(options.trackThickness ?? 0.18, 0.03, 0.65);
|
|
289
494
|
const trackWidth = Math.max(1, Math.round(outerRadius * trackThickness));
|
|
290
495
|
const maxRadius = Math.max(1, outerRadius - trackWidth);
|
|
@@ -304,10 +509,14 @@ function createSpiralTrack(viewportWidth, viewportHeight, options = {}) {
|
|
|
304
509
|
});
|
|
305
510
|
return buildTrack("spiral", nodes, false, trackWidth, pathFromNodes(nodes, false), pathFromNodes(offsetNodesByAngle(nodes, trackWidth), false));
|
|
306
511
|
}
|
|
512
|
+
|
|
513
|
+
// src/tracks/wave.ts
|
|
514
|
+
var DISC_MARGIN5 = 4;
|
|
515
|
+
var DEFAULT_WIDTH_RATIO2 = 0.9;
|
|
307
516
|
function createWaveTrack(viewportWidth, viewportHeight, options = {}) {
|
|
308
|
-
const widthRatio = clamp(options.widthRatio ??
|
|
517
|
+
const widthRatio = clamp(options.widthRatio ?? DEFAULT_WIDTH_RATIO2, 0.05, 1);
|
|
309
518
|
const amplitudeRatio = clamp(options.amplitudeRatio ?? 0.32, 0.02, 0.9);
|
|
310
|
-
const halfW = Math.max(1, Math.floor(viewportWidth / 2 * widthRatio) -
|
|
519
|
+
const halfW = Math.max(1, Math.floor(viewportWidth / 2 * widthRatio) - DISC_MARGIN5);
|
|
311
520
|
const trackWidth = Math.max(
|
|
312
521
|
1,
|
|
313
522
|
Math.round(Math.min(halfW, viewportHeight / 2) * clamp(options.trackThickness ?? 0.16, 0.03, 0.65))
|
|
@@ -322,6 +531,8 @@ function createWaveTrack(viewportWidth, viewportHeight, options = {}) {
|
|
|
322
531
|
}));
|
|
323
532
|
return buildTrack("wave", nodes, false, trackWidth, pathFromNodes(nodes, false), pathFromNodes(offsetNodesByTangent(nodes, trackWidth), false));
|
|
324
533
|
}
|
|
534
|
+
|
|
535
|
+
// src/tracks/blob.ts
|
|
325
536
|
function createBlobTrack(viewportWidth, viewportHeight, options = {}) {
|
|
326
537
|
const geometry = resolveGeometry(viewportWidth, viewportHeight, options);
|
|
327
538
|
const baseA = Math.max(1, geometry.outerHalfW - geometry.trackWidth);
|
|
@@ -347,6 +558,8 @@ function createBlobTrack(viewportWidth, viewportHeight, options = {}) {
|
|
|
347
558
|
});
|
|
348
559
|
return buildTrack("blob", nodes, true, geometry.trackWidth, pathFromNodes(nodes, true), pathFromNodes(blobOuter, true));
|
|
349
560
|
}
|
|
561
|
+
|
|
562
|
+
// src/tracks/svg-path.ts
|
|
350
563
|
function createSvgPolylineTrack(viewportWidth, viewportHeight, options) {
|
|
351
564
|
const trackWidth = Math.max(1, Math.min(viewportWidth, viewportHeight) * (options.trackThickness ?? 0.14));
|
|
352
565
|
const scale = clamp(options.scale ?? 1, 0.05, 4);
|
|
@@ -366,199 +579,8 @@ function createSvgPolylineTrack(viewportWidth, viewportHeight, options) {
|
|
|
366
579
|
pathFromNodes(offsetNodesByTangent(nodes, trackWidth), Boolean(options.closed))
|
|
367
580
|
);
|
|
368
581
|
}
|
|
369
|
-
function buildTrack(kind, nodes, closed, fallbackLineWidth, guidePath, outerGuidePath) {
|
|
370
|
-
const measured = measureNodes(nodes, closed);
|
|
371
|
-
const length = measured.at(-1)?.distance ?? 0;
|
|
372
|
-
return {
|
|
373
|
-
kind,
|
|
374
|
-
closed,
|
|
375
|
-
length,
|
|
376
|
-
lineWidth: fallbackLineWidth,
|
|
377
|
-
guidePath,
|
|
378
|
-
outerGuidePath,
|
|
379
|
-
sampleAt(distance) {
|
|
380
|
-
if (measured.length === 0) {
|
|
381
|
-
return { x: 0, y: 0, angleDeg: 0, tangentDeg: 0, lineWidth: fallbackLineWidth };
|
|
382
|
-
}
|
|
383
|
-
const target = closed ? positiveModulo(distance, length) : clamp(distance, 0, length);
|
|
384
|
-
let hi = measured.findIndex((node) => node.distance >= target);
|
|
385
|
-
if (hi <= 0) {
|
|
386
|
-
return measured[0];
|
|
387
|
-
}
|
|
388
|
-
const prev = measured[hi - 1];
|
|
389
|
-
const next = measured[hi];
|
|
390
|
-
const span = Math.max(1e-6, next.distance - prev.distance);
|
|
391
|
-
const amount = (target - prev.distance) / span;
|
|
392
|
-
const tangentDeg = angleBetween(prev.x, prev.y, next.x, next.y);
|
|
393
|
-
return {
|
|
394
|
-
x: lerp(prev.x, next.x, amount),
|
|
395
|
-
y: lerp(prev.y, next.y, amount),
|
|
396
|
-
tangentDeg,
|
|
397
|
-
angleDeg: lerpAngle(prev.angleDeg, next.angleDeg, amount),
|
|
398
|
-
lineWidth: lerp(prev.lineWidth, next.lineWidth, amount)
|
|
399
|
-
};
|
|
400
|
-
}
|
|
401
|
-
};
|
|
402
|
-
}
|
|
403
|
-
function sampleParametric(count, closed, sample) {
|
|
404
|
-
const points = [];
|
|
405
|
-
const end = closed ? count : count - 1;
|
|
406
|
-
for (let i = 0; i <= end; i += 1) {
|
|
407
|
-
const t = i / count;
|
|
408
|
-
const point = sample(t, i);
|
|
409
|
-
points.push({ ...point, tangentDeg: 0 });
|
|
410
|
-
}
|
|
411
|
-
for (let i = 0; i < points.length; i += 1) {
|
|
412
|
-
const prev = points[Math.max(0, i - 1)];
|
|
413
|
-
const next = points[Math.min(points.length - 1, i + 1)];
|
|
414
|
-
const tangentDeg = angleBetween(prev.x, prev.y, next.x, next.y);
|
|
415
|
-
points[i].tangentDeg = tangentDeg;
|
|
416
|
-
if (!Number.isFinite(points[i].angleDeg)) {
|
|
417
|
-
points[i].angleDeg = tangentDeg - 90;
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
return points;
|
|
421
|
-
}
|
|
422
|
-
function measureNodes(nodes, closed) {
|
|
423
|
-
if (nodes.length === 0) {
|
|
424
|
-
return [];
|
|
425
|
-
}
|
|
426
|
-
const measured = [{ ...nodes[0], distance: 0 }];
|
|
427
|
-
let distance = 0;
|
|
428
|
-
const limit = closed ? nodes.length : nodes.length - 1;
|
|
429
|
-
for (let i = 1; i <= limit; i += 1) {
|
|
430
|
-
const prev = nodes[i - 1];
|
|
431
|
-
const next = nodes[i % nodes.length];
|
|
432
|
-
distance += Math.hypot(next.x - prev.x, next.y - prev.y);
|
|
433
|
-
measured.push({ ...next, distance });
|
|
434
|
-
}
|
|
435
|
-
return measured;
|
|
436
|
-
}
|
|
437
|
-
function resolveGeometry(viewportWidth, viewportHeight, options) {
|
|
438
|
-
const widthRatio = clamp(options.widthRatio ?? DEFAULT_GEOMETRY.widthRatio, 0.05, 1);
|
|
439
|
-
const heightRatio = clamp(options.heightRatio ?? DEFAULT_GEOMETRY.heightRatio, 0.05, 1);
|
|
440
|
-
const trackThickness = clamp(options.trackThickness ?? DEFAULT_GEOMETRY.trackThickness, 0.05, 0.9);
|
|
441
|
-
const outerHalfW = Math.max(1, Math.floor(viewportWidth / 2 * widthRatio) - DISC_MARGIN);
|
|
442
|
-
const outerHalfH = Math.max(1, Math.floor(viewportHeight / 2 * heightRatio) - DISC_MARGIN);
|
|
443
|
-
const trackWidth = Math.max(1, Math.round(Math.min(outerHalfW, outerHalfH) * trackThickness));
|
|
444
|
-
return {
|
|
445
|
-
outerHalfW,
|
|
446
|
-
outerHalfH,
|
|
447
|
-
trackWidth,
|
|
448
|
-
cornerRadius: clamp(options.cornerRadius ?? DEFAULT_GEOMETRY.cornerRadius, 0, 1)
|
|
449
|
-
};
|
|
450
|
-
}
|
|
451
|
-
function roundedRectPoint(t, arcOffsetX, arcOffsetY, radius) {
|
|
452
|
-
const perimeter = 4 * arcOffsetX + 4 * arcOffsetY + 2 * Math.PI * radius;
|
|
453
|
-
let s = positiveUnit(t) * perimeter;
|
|
454
|
-
const quarterArc = Math.PI / 2 * radius;
|
|
455
|
-
if (s < 2 * arcOffsetX) {
|
|
456
|
-
return { x: -arcOffsetX + s, y: -(arcOffsetY + radius), angleDeg: -90 };
|
|
457
|
-
}
|
|
458
|
-
s -= 2 * arcOffsetX;
|
|
459
|
-
if (s < quarterArc) {
|
|
460
|
-
const alpha2 = -90 + s / quarterArc * 90;
|
|
461
|
-
const rad2 = alpha2 * Math.PI / 180;
|
|
462
|
-
return { x: arcOffsetX + radius * Math.cos(rad2), y: -arcOffsetY + radius * Math.sin(rad2), angleDeg: alpha2 };
|
|
463
|
-
}
|
|
464
|
-
s -= quarterArc;
|
|
465
|
-
if (s < 2 * arcOffsetY) {
|
|
466
|
-
return { x: arcOffsetX + radius, y: -arcOffsetY + s, angleDeg: 0 };
|
|
467
|
-
}
|
|
468
|
-
s -= 2 * arcOffsetY;
|
|
469
|
-
if (s < quarterArc) {
|
|
470
|
-
const alpha2 = s / quarterArc * 90;
|
|
471
|
-
const rad2 = alpha2 * Math.PI / 180;
|
|
472
|
-
return { x: arcOffsetX + radius * Math.cos(rad2), y: arcOffsetY + radius * Math.sin(rad2), angleDeg: alpha2 };
|
|
473
|
-
}
|
|
474
|
-
s -= quarterArc;
|
|
475
|
-
if (s < 2 * arcOffsetX) {
|
|
476
|
-
return { x: arcOffsetX - s, y: arcOffsetY + radius, angleDeg: 90 };
|
|
477
|
-
}
|
|
478
|
-
s -= 2 * arcOffsetX;
|
|
479
|
-
if (s < quarterArc) {
|
|
480
|
-
const alpha2 = 90 + s / quarterArc * 90;
|
|
481
|
-
const rad2 = alpha2 * Math.PI / 180;
|
|
482
|
-
return { x: -arcOffsetX + radius * Math.cos(rad2), y: arcOffsetY + radius * Math.sin(rad2), angleDeg: alpha2 };
|
|
483
|
-
}
|
|
484
|
-
s -= quarterArc;
|
|
485
|
-
if (s < 2 * arcOffsetY) {
|
|
486
|
-
return { x: -(arcOffsetX + radius), y: arcOffsetY - s, angleDeg: 180 };
|
|
487
|
-
}
|
|
488
|
-
s -= 2 * arcOffsetY;
|
|
489
|
-
const alpha = 180 + s / quarterArc * 90;
|
|
490
|
-
const rad = alpha * Math.PI / 180;
|
|
491
|
-
return { x: -arcOffsetX + radius * Math.cos(rad), y: -arcOffsetY + radius * Math.sin(rad), angleDeg: alpha };
|
|
492
|
-
}
|
|
493
|
-
function pathFromNodes(nodes, closed) {
|
|
494
|
-
if (nodes.length === 0) {
|
|
495
|
-
return "";
|
|
496
|
-
}
|
|
497
|
-
const path = [`M ${format(nodes[0].x)} ${format(nodes[0].y)}`];
|
|
498
|
-
for (let i = 1; i < nodes.length; i += 1) {
|
|
499
|
-
path.push(`L ${format(nodes[i].x)} ${format(nodes[i].y)}`);
|
|
500
|
-
}
|
|
501
|
-
if (closed) {
|
|
502
|
-
path.push("Z");
|
|
503
|
-
}
|
|
504
|
-
return path.join(" ");
|
|
505
|
-
}
|
|
506
|
-
function offsetNodesByAngle(nodes, distance) {
|
|
507
|
-
return nodes.map((node) => {
|
|
508
|
-
const angle = node.angleDeg * Math.PI / 180;
|
|
509
|
-
return {
|
|
510
|
-
...node,
|
|
511
|
-
x: node.x + Math.cos(angle) * distance,
|
|
512
|
-
y: node.y + Math.sin(angle) * distance
|
|
513
|
-
};
|
|
514
|
-
});
|
|
515
|
-
}
|
|
516
|
-
function offsetNodesByTangent(nodes, distance) {
|
|
517
|
-
return nodes.map((node) => {
|
|
518
|
-
const angle = (node.tangentDeg + 90) * Math.PI / 180;
|
|
519
|
-
return {
|
|
520
|
-
...node,
|
|
521
|
-
x: node.x + Math.cos(angle) * distance,
|
|
522
|
-
y: node.y + Math.sin(angle) * distance
|
|
523
|
-
};
|
|
524
|
-
});
|
|
525
|
-
}
|
|
526
|
-
function withPolylineTangents(nodes, closed) {
|
|
527
|
-
return nodes.map((node, index) => {
|
|
528
|
-
const prev = nodes[index - 1] ?? (closed ? nodes.at(-1) : node);
|
|
529
|
-
const next = nodes[index + 1] ?? (closed ? nodes[0] : node);
|
|
530
|
-
const tangentDeg = prev && next ? angleBetween(prev.x, prev.y, next.x, next.y) : 0;
|
|
531
|
-
return {
|
|
532
|
-
...node,
|
|
533
|
-
tangentDeg,
|
|
534
|
-
angleDeg: tangentDeg + 90
|
|
535
|
-
};
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
function angleBetween(x1, y1, x2, y2) {
|
|
539
|
-
return Math.atan2(y2 - y1, x2 - x1) * 180 / Math.PI;
|
|
540
|
-
}
|
|
541
|
-
function lerp(a, b, t) {
|
|
542
|
-
return a + (b - a) * t;
|
|
543
|
-
}
|
|
544
|
-
function lerpAngle(a, b, t) {
|
|
545
|
-
const delta = (b - a + 540) % 360 - 180;
|
|
546
|
-
return a + delta * t;
|
|
547
|
-
}
|
|
548
|
-
function positiveUnit(value) {
|
|
549
|
-
return positiveModulo(value, 1);
|
|
550
|
-
}
|
|
551
|
-
function positiveModulo(value, modulo) {
|
|
552
|
-
return (value % modulo + modulo) % modulo;
|
|
553
|
-
}
|
|
554
|
-
function clamp(value, min, max) {
|
|
555
|
-
return Math.min(max, Math.max(min, value));
|
|
556
|
-
}
|
|
557
|
-
function format(value) {
|
|
558
|
-
return Number(value.toFixed(2));
|
|
559
|
-
}
|
|
560
582
|
|
|
561
|
-
// src/
|
|
583
|
+
// src/RadialText/RadialText.tsx
|
|
562
584
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
563
585
|
var DEFAULT_GEOMETRY2 = {
|
|
564
586
|
stadium: {
|
|
@@ -901,7 +923,7 @@ function createSvgPathShape(width, height, config) {
|
|
|
901
923
|
return createSvgPolylineTrack(width, height, config.geometry.svgPath);
|
|
902
924
|
}
|
|
903
925
|
|
|
904
|
-
// src/
|
|
926
|
+
// src/export/png.ts
|
|
905
927
|
import html2canvas from "html2canvas";
|
|
906
928
|
var GUIDES_ACTIVE_CLASS = "radialText--guides";
|
|
907
929
|
async function exportDiscAsPng(discEl, rootEl, filename = "radial-text.png") {
|
|
@@ -936,7 +958,7 @@ async function exportDiscAsPng(discEl, rootEl, filename = "radial-text.png") {
|
|
|
936
958
|
}
|
|
937
959
|
}
|
|
938
960
|
|
|
939
|
-
// src/
|
|
961
|
+
// src/export/svg.ts
|
|
940
962
|
function exportDiscAsSvg(discEl, filename = "radial-text.svg") {
|
|
941
963
|
const w = discEl.offsetWidth;
|
|
942
964
|
const h = discEl.offsetHeight;
|
package/package.json
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pswaqtch/around",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"files": [
|
|
5
|
+
"files": [
|
|
6
|
+
"dist"
|
|
7
|
+
],
|
|
6
8
|
"publishConfig": {
|
|
7
9
|
"access": "public"
|
|
8
10
|
},
|
|
@@ -20,7 +22,8 @@
|
|
|
20
22
|
"dev": "vite --host 127.0.0.1",
|
|
21
23
|
"build": "tsup",
|
|
22
24
|
"build:demo": "tsc && vite build",
|
|
23
|
-
"typecheck": "tsc --noEmit"
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"release": "npm run build && npm version patch && npm publish --access public"
|
|
24
27
|
},
|
|
25
28
|
"peerDependencies": {
|
|
26
29
|
"react": ">=18",
|