@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 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
@@ -1,4 +1,4 @@
1
- /* src/components/RadialText/RadialText.css */
1
+ /* src/RadialText/RadialText.css */
2
2
  .radialText {
3
3
  position: relative;
4
4
  }
package/dist/index.js CHANGED
@@ -1,21 +1,7 @@
1
- // src/components/RadialText/RadialText.tsx
1
+ // src/RadialText/RadialText.tsx
2
2
  import { useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
3
3
 
4
- // src/lib/article-layout.ts
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/lib/pretext-wrap.ts
162
- import { layoutWithLines, prepareWithSegments } from "@chenglou/pretext";
163
- var LINE_HEIGHT_RATIO = 1.12;
164
- var measureContext;
165
- function measureTextWidth(text, style) {
166
- const context = getMeasureContext();
167
- if (!context) {
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
- context.font = toCanvasFont(style);
171
- return context.measureText(text).width;
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 fillLine(char, maxWidth, style) {
174
- const charWidth = measureTextWidth(char, style);
175
- const repeatCount = charWidth > 0 ? Math.floor(maxWidth / charWidth) : 1;
176
- return char.repeat(Math.max(1, repeatCount));
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 createPretextWrapper() {
179
- const preparedCache = /* @__PURE__ */ new Map();
180
- return function wrapText(text, maxWidth, style) {
181
- const font = toCanvasFont(style);
182
- const key = `${font}
183
- ${text}`;
184
- let prepared = preparedCache.get(key);
185
- if (!prepared) {
186
- prepared = prepareWithSegments(text, font);
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 getMeasureContext() {
193
- if (measureContext !== void 0) {
194
- return measureContext;
195
- }
196
- if (typeof document === "undefined") {
197
- measureContext = null;
198
- return measureContext;
199
- }
200
- const canvas = document.createElement("canvas");
201
- measureContext = canvas.getContext("2d");
202
- return measureContext;
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 toCanvasFont(style) {
205
- const fontStyle = style.italic ? "italic " : "";
206
- return `${fontStyle}${style.weight} ${style.fontSize}px ${style.family}`;
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/lib/text-track.ts
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)) - DISC_MARGIN) : Math.max(1, outerHalfW - geometry.trackWidth);
247
- const innerHalfH = options.innerHeightRatio != null ? Math.max(1, Math.floor(viewportHeight / 2 * clamp(options.innerHeightRatio, 0.05, 1)) - DISC_MARGIN) : Math.max(1, outerHalfH - geometry.trackWidth);
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)) - DISC_MARGIN) : Math.max(1, outerA - geometry.trackWidth);
263
- const innerB = options.innerHeightRatio != null ? Math.max(1, Math.floor(viewportHeight / 2 * clamp(options.innerHeightRatio, 0.05, 1)) - DISC_MARGIN) : Math.max(1, outerB - geometry.trackWidth);
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 ?? DEFAULT_GEOMETRY.widthRatio, 0.05, 1);
287
- const outerRadius = Math.max(1, Math.floor(Math.min(viewportWidth, viewportHeight) / 2 * scale) - DISC_MARGIN);
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 ?? DEFAULT_GEOMETRY.widthRatio, 0.05, 1);
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) - DISC_MARGIN);
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/components/RadialText/RadialText.tsx
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/lib/export-disc.ts
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/lib/export-disc-svg.ts
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.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
- "files": ["dist"],
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",