@pswaqtch/around 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.css ADDED
@@ -0,0 +1,68 @@
1
+ /* src/components/RadialText/RadialText.css */
2
+ .radialText {
3
+ position: relative;
4
+ }
5
+ .radialText__disc {
6
+ position: fixed;
7
+ inset: 0;
8
+ width: 100vw;
9
+ height: 100dvh;
10
+ background:
11
+ linear-gradient(
12
+ 135deg,
13
+ rgba(255, 255, 255, 0.72),
14
+ rgba(232, 235, 231, 0.52)),
15
+ #f3f4f2;
16
+ overflow: hidden;
17
+ }
18
+ .radialText__scrollSpacer {
19
+ height: var(--radial-scroll-height, 260vh);
20
+ pointer-events: none;
21
+ }
22
+ .radialText__guides {
23
+ position: absolute;
24
+ inset: 0;
25
+ width: 100%;
26
+ height: 100%;
27
+ overflow: visible;
28
+ pointer-events: none;
29
+ }
30
+ .radialText__guidePath {
31
+ fill: none;
32
+ stroke: rgba(78, 100, 87, 0.34);
33
+ stroke-width: 1;
34
+ opacity: 0;
35
+ transition: opacity 180ms ease;
36
+ vector-effect: non-scaling-stroke;
37
+ }
38
+ .radialText--guides .radialText__guidePath {
39
+ opacity: 1;
40
+ }
41
+ .radialText__line {
42
+ position: absolute;
43
+ left: 50%;
44
+ top: 50%;
45
+ box-sizing: border-box;
46
+ padding-left: 0;
47
+ padding-right: 0;
48
+ color: var(--radial-text-color, #171717);
49
+ font-size: 9px;
50
+ line-height: 1;
51
+ text-align-last: inherit;
52
+ transform-origin: left center;
53
+ user-select: text;
54
+ white-space: nowrap;
55
+ }
56
+ .radialText__line--heading {
57
+ color: #111513;
58
+ }
59
+ .radialText__line--quote {
60
+ color: #565c57;
61
+ }
62
+ .radialText__line--spacer,
63
+ .radialText__line--separator {
64
+ user-select: none;
65
+ }
66
+ .radialText__line--separator {
67
+ color: #a8ada8;
68
+ }
@@ -0,0 +1,98 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { Ref } from 'react';
3
+
4
+ type RadialTextAlign = "left" | "right" | "justify";
5
+
6
+ interface StadiumTrackOptions {
7
+ widthRatio?: number;
8
+ heightRatio?: number;
9
+ innerWidthRatio?: number;
10
+ innerHeightRatio?: number;
11
+ trackThickness?: number;
12
+ cornerRadius?: number;
13
+ }
14
+ interface EllipseTrackOptions {
15
+ widthRatio?: number;
16
+ heightRatio?: number;
17
+ innerWidthRatio?: number;
18
+ innerHeightRatio?: number;
19
+ trackThickness?: number;
20
+ }
21
+ interface SpiralTrackOptions {
22
+ scale?: number;
23
+ turns?: number;
24
+ innerRadiusRatio?: number;
25
+ trackThickness?: number;
26
+ }
27
+ interface WaveTrackOptions {
28
+ widthRatio?: number;
29
+ amplitudeRatio?: number;
30
+ cycles?: number;
31
+ trackThickness?: number;
32
+ }
33
+ interface BlobTrackOptions {
34
+ widthRatio?: number;
35
+ heightRatio?: number;
36
+ wobble?: number;
37
+ lobes?: number;
38
+ trackThickness?: number;
39
+ }
40
+ interface SvgPolylineTrackOptions {
41
+ points: Array<[number, number]>;
42
+ scale?: number;
43
+ trackThickness?: number;
44
+ closed?: boolean;
45
+ }
46
+
47
+ type RadialShapeKind = "stadium" | "ellipse" | "spiral" | "wave" | "blob" | "svg-path";
48
+ interface RadialTextGeometry {
49
+ stadium?: StadiumTrackOptions;
50
+ ellipse?: EllipseTrackOptions;
51
+ spiral?: SpiralTrackOptions;
52
+ wave?: WaveTrackOptions;
53
+ blob?: BlobTrackOptions;
54
+ svgPath?: Partial<SvgPolylineTrackOptions>;
55
+ }
56
+ interface RadialTextLayout {
57
+ align?: RadialTextAlign;
58
+ textInset?: number;
59
+ lineSpacing?: number;
60
+ minScrollHeightVh?: number;
61
+ linesPerViewport?: number;
62
+ }
63
+ interface RadialTextTypography {
64
+ fontFamily?: string;
65
+ bodySize?: number;
66
+ headingSize?: number;
67
+ quoteSize?: number;
68
+ bodyWeight?: number;
69
+ headingWeight?: number;
70
+ }
71
+ /** Imperative handle exposed to parent components for export. */
72
+ interface RadialTextHandle {
73
+ /** The viewport-filling disc element — pass directly to html2canvas. */
74
+ discEl: HTMLDivElement;
75
+ /** The component root element — carries the `radialText--guides` class. */
76
+ rootEl: HTMLDivElement;
77
+ }
78
+ interface RadialTextProps {
79
+ text: string;
80
+ shape?: RadialShapeKind;
81
+ loop?: boolean;
82
+ geometry?: RadialTextGeometry;
83
+ layout?: RadialTextLayout;
84
+ typography?: RadialTextTypography;
85
+ className?: string;
86
+ showGuides?: boolean;
87
+ discBg?: string;
88
+ textColor?: string;
89
+ /** Optional ref to receive imperative access to disc and root elements. */
90
+ ref?: Ref<RadialTextHandle>;
91
+ }
92
+ declare function RadialText({ text, shape, loop, geometry, layout, typography, className, showGuides, discBg, textColor, ref, }: RadialTextProps): react_jsx_runtime.JSX.Element;
93
+
94
+ declare function exportDiscAsPng(discEl: HTMLElement, rootEl: HTMLElement, filename?: string): Promise<void>;
95
+
96
+ declare function exportDiscAsSvg(discEl: HTMLElement, filename?: string): void;
97
+
98
+ export { type RadialShapeKind, RadialText, type RadialTextAlign, type RadialTextGeometry, type RadialTextHandle, type RadialTextLayout, type RadialTextProps, type RadialTextTypography, exportDiscAsPng, exportDiscAsSvg };
package/dist/index.js ADDED
@@ -0,0 +1,985 @@
1
+ // src/components/RadialText/RadialText.tsx
2
+ import { useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
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
+ });
19
+ var HEADING_MARKER_RE = /^(#{1,6})\s+(.+)$/;
20
+ var LIST_MARKER_RE = /^[-*+]\s+(.+)$/;
21
+ var NUMBERED_MARKER_RE = /^\d+[.)]\s+(.+)$/;
22
+ var QUOTE_MARKER_RE = /^>\s?(.+)$/;
23
+ var THEMATIC_BREAK_RE = /^([-*_=])\1{2,}\s*$/;
24
+ function parseArticle(source) {
25
+ const normalized = source.replace(/\r\n?/g, "\n").trim();
26
+ if (!normalized) {
27
+ return [];
28
+ }
29
+ const lines = normalized.split("\n");
30
+ const blocks = [];
31
+ let paragraph = [];
32
+ function flushParagraph() {
33
+ if (paragraph.length === 0) {
34
+ return;
35
+ }
36
+ blocks.push({
37
+ kind: "paragraph",
38
+ text: normalizeInlineText(paragraph.join(" "))
39
+ });
40
+ paragraph = [];
41
+ }
42
+ lines.forEach((rawLine, index) => {
43
+ const line = rawLine.trim();
44
+ if (!line) {
45
+ flushParagraph();
46
+ return;
47
+ }
48
+ const heading = line.match(HEADING_MARKER_RE);
49
+ if (heading) {
50
+ flushParagraph();
51
+ blocks.push({
52
+ kind: "heading",
53
+ text: normalizeInlineText(heading[2] ?? ""),
54
+ level: Math.min(heading[1]?.length ?? 1, 3)
55
+ });
56
+ return;
57
+ }
58
+ const listItem = line.match(LIST_MARKER_RE) ?? line.match(NUMBERED_MARKER_RE);
59
+ if (listItem) {
60
+ flushParagraph();
61
+ blocks.push({
62
+ kind: "list-item",
63
+ text: normalizeInlineText(listItem[1] ?? "")
64
+ });
65
+ return;
66
+ }
67
+ const quote = line.match(QUOTE_MARKER_RE);
68
+ if (quote) {
69
+ flushParagraph();
70
+ blocks.push({
71
+ kind: "quote",
72
+ text: normalizeInlineText(quote[1] ?? "")
73
+ });
74
+ return;
75
+ }
76
+ if (THEMATIC_BREAK_RE.test(line)) {
77
+ flushParagraph();
78
+ return;
79
+ }
80
+ const previousLine = index > 0 ? lines[index - 1]?.trim() ?? "" : "";
81
+ const nextLine = lines[index + 1]?.trim() ?? "";
82
+ const looksLikeTitle = blocks.length === 0 && paragraph.length === 0 && index <= 1 && line.length <= 90 && !/[.!,:;]$/.test(line) && nextLine.length > 0 && previousLine.length === 0;
83
+ if (looksLikeTitle) {
84
+ blocks.push({
85
+ kind: "heading",
86
+ text: normalizeInlineText(line),
87
+ level: 1
88
+ });
89
+ return;
90
+ }
91
+ paragraph.push(line);
92
+ });
93
+ flushParagraph();
94
+ return blocks;
95
+ }
96
+ function buildArticleLines(source, options) {
97
+ const maxWidth = options.maxWidth;
98
+ const wrapText = options.wrapText;
99
+ const typography = options.typography;
100
+ const blocks = parseArticle(source);
101
+ const lines = [];
102
+ blocks.forEach((block, index) => {
103
+ const previous = blocks[index - 1];
104
+ if (index > 0 && previous && shouldInsertSpacer(previous, block)) {
105
+ lines.push(createSpacerLine(typography));
106
+ }
107
+ const style = getStyle(block.kind, typography);
108
+ const prefix = block.kind === "list-item" ? "- " : block.kind === "quote" ? '"' : "";
109
+ const suffix = block.kind === "quote" ? '"' : "";
110
+ const wrapped = wrapText(`${prefix}${block.text}${suffix}`, maxWidth, style);
111
+ wrapped.forEach((text, lineIndex) => {
112
+ lines.push({
113
+ kind: lineIndex > 0 && block.kind === "list-item" ? "list-continuation" : block.kind,
114
+ text: lineIndex > 0 && block.kind === "list-item" ? ` ${text}` : text,
115
+ fontSize: style.fontSize,
116
+ weight: style.weight,
117
+ family: style.family,
118
+ italic: Boolean(style.italic)
119
+ });
120
+ });
121
+ });
122
+ return lines;
123
+ }
124
+ function calculateScrollStartT(scrollY, maxScroll, direction = 1) {
125
+ if (maxScroll <= 0) {
126
+ return scrollY * direction;
127
+ }
128
+ return scrollY / maxScroll * direction;
129
+ }
130
+ function getStyle(kind, typography) {
131
+ const baseStyle = STYLE_BY_KIND[kind] ?? DEFAULT_BODY_STYLE;
132
+ const bodyOverride = typography?.paragraph ?? {};
133
+ const kindOverride = typography?.[kind] ?? {};
134
+ return {
135
+ ...baseStyle,
136
+ ...bodyOverride,
137
+ ...kindOverride
138
+ };
139
+ }
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
+ function shouldInsertSpacer(previous, current) {
144
+ if (current.kind === "list-item" && previous.kind === "list-item") {
145
+ return false;
146
+ }
147
+ return true;
148
+ }
149
+ function createSpacerLine(typography) {
150
+ const style = getStyle("spacer", typography);
151
+ return {
152
+ kind: "spacer",
153
+ text: "",
154
+ fontSize: 7,
155
+ weight: style.weight,
156
+ family: style.family,
157
+ italic: false
158
+ };
159
+ }
160
+
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;
169
+ }
170
+ context.font = toCanvasFont(style);
171
+ return context.measureText(text).width;
172
+ }
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));
177
+ }
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
+ };
191
+ }
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;
203
+ }
204
+ function toCanvasFont(style) {
205
+ const fontStyle = style.italic ? "italic " : "";
206
+ return `${fontStyle}${style.weight} ${style.fontSize}px ${style.family}`;
207
+ }
208
+
209
+ // src/lib/text-track.ts
210
+ var DISC_MARGIN = 4;
211
+ var DEFAULT_GEOMETRY = {
212
+ widthRatio: 0.9,
213
+ heightRatio: 0.9,
214
+ trackThickness: 0.56,
215
+ cornerRadius: 1
216
+ };
217
+ function sampleTextTrackLines(lines, track, startT = 0, minSpacing = 12, loop = false) {
218
+ if (lines.length === 0 || track.length <= 0) {
219
+ return [];
220
+ }
221
+ const usableLength = track.closed ? track.length - minSpacing : track.length;
222
+ const numSlots = Math.ceil(usableLength / minSpacing) + 1;
223
+ const beltPos = loop ? positiveUnit(startT) * lines.length : clamp(startT, 0, 1) * lines.length;
224
+ const firstIdx = Math.floor(beltPos);
225
+ const frac = beltPos - firstIdx;
226
+ const result = [];
227
+ for (let i = 0; i < numSlots; i += 1) {
228
+ const distance = (i - frac) * minSpacing;
229
+ const lineIndex = loop ? (firstIdx + i) % lines.length : firstIdx + i;
230
+ if (distance < 0 || distance >= usableLength || lineIndex < 0 || lineIndex >= lines.length) {
231
+ result.push(null);
232
+ continue;
233
+ }
234
+ result.push({
235
+ ...lines[lineIndex],
236
+ ...track.sampleAt(distance),
237
+ _lineIndex: lineIndex
238
+ });
239
+ }
240
+ return result;
241
+ }
242
+ function createStadiumTrack(viewportWidth, viewportHeight, options = {}) {
243
+ const geometry = resolveGeometry(viewportWidth, viewportHeight, options);
244
+ const outerHalfW = geometry.outerHalfW;
245
+ 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);
248
+ const trackWidth = Math.min(outerHalfW - innerHalfW, outerHalfH - innerHalfH);
249
+ const radius = Math.min(innerHalfW, innerHalfH) * geometry.cornerRadius;
250
+ const arcOffsetX = Math.max(0, innerHalfW - radius);
251
+ const arcOffsetY = Math.max(0, innerHalfH - radius);
252
+ const nodes = sampleParametric(720, true, (t) => ({
253
+ ...roundedRectPoint(t, arcOffsetX, arcOffsetY, radius),
254
+ lineWidth: trackWidth
255
+ }));
256
+ return buildTrack("stadium", nodes, true, trackWidth, pathFromNodes(nodes, true), pathFromNodes(offsetNodesByAngle(nodes, trackWidth), true));
257
+ }
258
+ function createEllipseTrack(viewportWidth, viewportHeight, options = {}) {
259
+ const geometry = resolveGeometry(viewportWidth, viewportHeight, options);
260
+ const outerA = geometry.outerHalfW;
261
+ 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);
264
+ const trackWidth = Math.min(outerA - innerA, outerB - innerB);
265
+ const nodes = sampleParametric(960, true, (t) => {
266
+ const angle = t * Math.PI * 2 - Math.PI / 2;
267
+ const cosA = Math.cos(angle);
268
+ const sinA = Math.sin(angle);
269
+ const x = innerA * cosA;
270
+ const y = innerB * sinA;
271
+ const gx = cosA / innerA;
272
+ const gy = sinA / innerB;
273
+ const gl = Math.hypot(gx, gy) || 1;
274
+ const nx = gx / gl;
275
+ const ny = gy / gl;
276
+ return {
277
+ x,
278
+ y,
279
+ angleDeg: Math.atan2(ny, nx) * 180 / Math.PI,
280
+ lineWidth: trackWidth
281
+ };
282
+ });
283
+ return buildTrack("ellipse", nodes, true, trackWidth, pathFromNodes(nodes, true), pathFromNodes(offsetNodesByAngle(nodes, trackWidth), true));
284
+ }
285
+ 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);
288
+ const trackThickness = clamp(options.trackThickness ?? 0.18, 0.03, 0.65);
289
+ const trackWidth = Math.max(1, Math.round(outerRadius * trackThickness));
290
+ const maxRadius = Math.max(1, outerRadius - trackWidth);
291
+ const innerRadiusRatio = clamp(options.innerRadiusRatio ?? 0.18, 0.02, 0.8);
292
+ const minRadius = Math.max(1, maxRadius * innerRadiusRatio);
293
+ const turns = clamp(options.turns ?? 2.85, 0.5, 8);
294
+ const thetaMax = turns * Math.PI * 2;
295
+ const nodes = sampleParametric(960, false, (t) => {
296
+ const theta = t * thetaMax - Math.PI / 2;
297
+ const radius = minRadius + (maxRadius - minRadius) * t;
298
+ return {
299
+ x: radius * Math.cos(theta),
300
+ y: radius * Math.sin(theta),
301
+ angleDeg: theta * 180 / Math.PI,
302
+ lineWidth: trackWidth
303
+ };
304
+ });
305
+ return buildTrack("spiral", nodes, false, trackWidth, pathFromNodes(nodes, false), pathFromNodes(offsetNodesByAngle(nodes, trackWidth), false));
306
+ }
307
+ function createWaveTrack(viewportWidth, viewportHeight, options = {}) {
308
+ const widthRatio = clamp(options.widthRatio ?? DEFAULT_GEOMETRY.widthRatio, 0.05, 1);
309
+ const amplitudeRatio = clamp(options.amplitudeRatio ?? 0.32, 0.02, 0.9);
310
+ const halfW = Math.max(1, Math.floor(viewportWidth / 2 * widthRatio) - DISC_MARGIN);
311
+ const trackWidth = Math.max(
312
+ 1,
313
+ Math.round(Math.min(halfW, viewportHeight / 2) * clamp(options.trackThickness ?? 0.16, 0.03, 0.65))
314
+ );
315
+ const amplitude = Math.max(1, viewportHeight / 2 * amplitudeRatio - trackWidth);
316
+ const cycles = clamp(options.cycles ?? 2.4, 0.25, 8);
317
+ const nodes = sampleParametric(720, false, (t) => ({
318
+ x: -halfW + halfW * 2 * t,
319
+ y: Math.sin(t * Math.PI * 2 * cycles) * amplitude,
320
+ angleDeg: 0,
321
+ lineWidth: trackWidth
322
+ }));
323
+ return buildTrack("wave", nodes, false, trackWidth, pathFromNodes(nodes, false), pathFromNodes(offsetNodesByTangent(nodes, trackWidth), false));
324
+ }
325
+ function createBlobTrack(viewportWidth, viewportHeight, options = {}) {
326
+ const geometry = resolveGeometry(viewportWidth, viewportHeight, options);
327
+ const baseA = Math.max(1, geometry.outerHalfW - geometry.trackWidth);
328
+ const baseB = Math.max(1, geometry.outerHalfH - geometry.trackWidth);
329
+ const wobble = clamp(options.wobble ?? 0.16, 0, 0.45);
330
+ const lobes = Math.max(2, Math.round(clamp(options.lobes ?? 5, 2, 12)));
331
+ const blobInner = sampleParametric(960, true, (t) => {
332
+ const theta = t * Math.PI * 2 - Math.PI / 2;
333
+ const radiusScale = 1 + wobble * Math.sin(theta * lobes + 0.6) + wobble * 0.55 * Math.sin(theta * (lobes + 2) - 0.9);
334
+ return {
335
+ x: baseA * radiusScale * Math.cos(theta),
336
+ y: baseB * radiusScale * Math.sin(theta),
337
+ angleDeg: theta * 180 / Math.PI,
338
+ lineWidth: geometry.trackWidth
339
+ };
340
+ });
341
+ const blobOuter = offsetNodesByAngle(blobInner, geometry.trackWidth);
342
+ const nodes = blobInner.map((node) => {
343
+ const nearest = blobOuter.reduce(
344
+ (best, o) => Math.hypot(o.x - node.x, o.y - node.y) < Math.hypot(best.x - node.x, best.y - node.y) ? o : best
345
+ );
346
+ return { ...node, lineWidth: Math.hypot(nearest.x - node.x, nearest.y - node.y) };
347
+ });
348
+ return buildTrack("blob", nodes, true, geometry.trackWidth, pathFromNodes(nodes, true), pathFromNodes(blobOuter, true));
349
+ }
350
+ function createSvgPolylineTrack(viewportWidth, viewportHeight, options) {
351
+ const trackWidth = Math.max(1, Math.min(viewportWidth, viewportHeight) * (options.trackThickness ?? 0.14));
352
+ const scale = clamp(options.scale ?? 1, 0.05, 4);
353
+ const nodes = withPolylineTangents(options.points.map(([x, y]) => ({
354
+ x: x * scale,
355
+ y: y * scale,
356
+ angleDeg: 0,
357
+ tangentDeg: 0,
358
+ lineWidth: trackWidth
359
+ })), Boolean(options.closed));
360
+ return buildTrack(
361
+ "svg-path",
362
+ nodes,
363
+ Boolean(options.closed),
364
+ trackWidth,
365
+ pathFromNodes(nodes, Boolean(options.closed)),
366
+ pathFromNodes(offsetNodesByTangent(nodes, trackWidth), Boolean(options.closed))
367
+ );
368
+ }
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
+
561
+ // src/components/RadialText/RadialText.tsx
562
+ import { jsx, jsxs } from "react/jsx-runtime";
563
+ var DEFAULT_GEOMETRY2 = {
564
+ stadium: {
565
+ widthRatio: 0.9,
566
+ heightRatio: 0.9,
567
+ trackThickness: 0.56,
568
+ cornerRadius: 1
569
+ },
570
+ ellipse: {
571
+ widthRatio: 0.9,
572
+ heightRatio: 0.9,
573
+ trackThickness: 0.56
574
+ },
575
+ spiral: {
576
+ scale: 0.76,
577
+ turns: 2.85,
578
+ innerRadiusRatio: 0.18,
579
+ trackThickness: 0.18
580
+ },
581
+ wave: {
582
+ widthRatio: 0.9,
583
+ amplitudeRatio: 0.32,
584
+ cycles: 2.4,
585
+ trackThickness: 0.16
586
+ },
587
+ blob: {
588
+ widthRatio: 0.82,
589
+ heightRatio: 0.78,
590
+ wobble: 0.16,
591
+ lobes: 5,
592
+ trackThickness: 0.2
593
+ },
594
+ svgPath: {
595
+ points: [
596
+ [-420, -120],
597
+ [-180, 110],
598
+ [120, -90],
599
+ [430, 130]
600
+ ],
601
+ scale: 1,
602
+ trackThickness: 0.16,
603
+ closed: false
604
+ }
605
+ };
606
+ var DEFAULT_LAYOUT = {
607
+ align: "left",
608
+ textInset: 6,
609
+ lineSpacing: 12,
610
+ minScrollHeightVh: 260,
611
+ linesPerViewport: 80
612
+ };
613
+ var DEFAULT_TYPOGRAPHY = {
614
+ fontFamily: "Georgia, serif",
615
+ bodySize: 9,
616
+ headingSize: 11,
617
+ quoteSize: 9,
618
+ bodyWeight: 400,
619
+ headingWeight: 700
620
+ };
621
+ var RADIAL_TRACK_FACTORIES = {
622
+ stadium: createStadiumShape,
623
+ ellipse: createEllipseShape,
624
+ spiral: createSpiralShape,
625
+ wave: createWaveShape,
626
+ blob: createBlobShape,
627
+ "svg-path": createSvgPathShape
628
+ };
629
+ function RadialText({
630
+ text,
631
+ shape = "stadium",
632
+ loop = false,
633
+ geometry,
634
+ layout,
635
+ typography,
636
+ className,
637
+ showGuides = true,
638
+ discBg,
639
+ textColor,
640
+ ref
641
+ }) {
642
+ const rootRef = useRef(null);
643
+ const discRef = useRef(null);
644
+ const guideSvgRef = useRef(null);
645
+ const guidePathRef = useRef(null);
646
+ const outerGuidePathRef = useRef(null);
647
+ useImperativeHandle(
648
+ ref,
649
+ () => ({
650
+ get discEl() {
651
+ if (!discRef.current) throw new Error("RadialText disc not mounted");
652
+ return discRef.current;
653
+ },
654
+ get rootEl() {
655
+ if (!rootRef.current) throw new Error("RadialText root not mounted");
656
+ return rootRef.current;
657
+ }
658
+ }),
659
+ []
660
+ );
661
+ const [scrollHeightVh, setScrollHeightVh] = useState(DEFAULT_LAYOUT.minScrollHeightVh);
662
+ const resolvedConfig = useMemo(
663
+ () => resolveRadialTextConfig(geometry, layout, typography),
664
+ [geometry, layout, typography]
665
+ );
666
+ const rootClassName = [
667
+ "radialText",
668
+ showGuides ? "radialText--guides" : "",
669
+ className ?? ""
670
+ ].filter(Boolean).join(" ");
671
+ const style = {
672
+ "--radial-scroll-height": `${scrollHeightVh}vh`
673
+ };
674
+ useEffect(() => {
675
+ const disc = discRef.current;
676
+ const guideSvg = guideSvgRef.current;
677
+ const guidePath = guidePathRef.current;
678
+ const outerGuidePath = outerGuidePathRef.current;
679
+ if (!disc || !guideSvg || !guidePath || !outerGuidePath || typeof window === "undefined") {
680
+ return void 0;
681
+ }
682
+ const discElement = disc;
683
+ const guideSvgElement = guideSvg;
684
+ const guidePathElement = guidePath;
685
+ const outerGuidePathElement = outerGuidePath;
686
+ const wrapText = createPretextWrapper();
687
+ let disposed = false;
688
+ let articleLines = [];
689
+ let lineElements = [];
690
+ let prevLineIndices = [];
691
+ let activeTrack = createTrack(shape, resolvedConfig);
692
+ let latestScrollY = window.scrollY;
693
+ let tickFrame = 0;
694
+ let resizeFrame = 0;
695
+ let ticking = false;
696
+ function createTrack(kind, config) {
697
+ return RADIAL_TRACK_FACTORIES[kind](window.innerWidth, window.innerHeight, config);
698
+ }
699
+ function applyGeometry() {
700
+ activeTrack = createTrack(shape, resolvedConfig);
701
+ guideSvgElement.setAttribute(
702
+ "viewBox",
703
+ `${-window.innerWidth / 2} ${-window.innerHeight / 2} ${window.innerWidth} ${window.innerHeight}`
704
+ );
705
+ guidePathElement.setAttribute("d", activeTrack.guidePath);
706
+ outerGuidePathElement.setAttribute("d", activeTrack.outerGuidePath);
707
+ }
708
+ function buildLines() {
709
+ const maxWidth = Math.max(1, activeTrack.lineWidth - resolvedConfig.textInset * 2);
710
+ const separatorStyle = getStyle("separator", resolvedConfig.typography);
711
+ const separatorText = fillLine("=", maxWidth, separatorStyle);
712
+ articleLines = buildArticleLines(text, {
713
+ maxWidth,
714
+ wrapText,
715
+ typography: resolvedConfig.typography
716
+ });
717
+ articleLines.push({
718
+ kind: "separator",
719
+ text: separatorText,
720
+ fontSize: separatorStyle.fontSize,
721
+ weight: separatorStyle.weight,
722
+ family: separatorStyle.family,
723
+ italic: false
724
+ });
725
+ const nextScrollHeight = Math.max(
726
+ resolvedConfig.minScrollHeightVh,
727
+ Math.ceil(articleLines.length / resolvedConfig.linesPerViewport) * 100
728
+ );
729
+ if (!disposed) {
730
+ setScrollHeightVh(nextScrollHeight);
731
+ }
732
+ }
733
+ function renderRadial() {
734
+ const numSlots = Math.ceil(activeTrack.length / resolvedConfig.lineSpacing) + 1;
735
+ discElement.querySelectorAll(".radialText__line").forEach((element) => element.remove());
736
+ lineElements = [];
737
+ prevLineIndices = [];
738
+ const fragment = document.createDocumentFragment();
739
+ for (let i = 0; i < numSlots; i += 1) {
740
+ const line = document.createElement("div");
741
+ line.className = "radialText__line";
742
+ line.style.visibility = "hidden";
743
+ lineElements.push(line);
744
+ fragment.appendChild(line);
745
+ }
746
+ discElement.appendChild(fragment);
747
+ updateConveyorBelt();
748
+ }
749
+ function updateConveyorBelt() {
750
+ const maxScroll = Math.max(1, document.documentElement.scrollHeight - window.innerHeight);
751
+ const startT = calculateScrollStartT(latestScrollY, maxScroll, 1);
752
+ const placed = sampleTextTrackLines(
753
+ articleLines,
754
+ activeTrack,
755
+ startT,
756
+ resolvedConfig.lineSpacing,
757
+ loop && activeTrack.closed
758
+ );
759
+ const isJustify = resolvedConfig.align === "justify";
760
+ for (let i = 0; i < lineElements.length; i += 1) {
761
+ const element = lineElements[i];
762
+ const line = placed[i];
763
+ if (!element) {
764
+ continue;
765
+ }
766
+ if (!line) {
767
+ element.style.visibility = "hidden";
768
+ continue;
769
+ }
770
+ element.style.visibility = "";
771
+ element.style.transform = lineTransform(line);
772
+ if (line._lineIndex !== prevLineIndices[i]) {
773
+ element.className = `radialText__line radialText__line--${line.kind}`;
774
+ element.textContent = line.text;
775
+ element.style.width = `${line.lineWidth}px`;
776
+ element.style.fontSize = `${line.fontSize}px`;
777
+ element.style.fontWeight = String(line.weight);
778
+ element.style.fontFamily = line.family;
779
+ element.style.fontStyle = line.italic ? "italic" : "normal";
780
+ element.style.paddingLeft = `${resolvedConfig.textInset}px`;
781
+ element.style.paddingRight = `${resolvedConfig.textInset}px`;
782
+ element.style.textAlign = isJustify ? "justify" : resolvedConfig.align;
783
+ element.style.textAlignLast = isJustify ? "justify" : "";
784
+ prevLineIndices[i] = line._lineIndex;
785
+ }
786
+ }
787
+ ticking = false;
788
+ }
789
+ function requestConveyorUpdate() {
790
+ latestScrollY = window.scrollY;
791
+ if (!ticking) {
792
+ tickFrame = window.requestAnimationFrame(updateConveyorBelt);
793
+ ticking = true;
794
+ }
795
+ }
796
+ function rebuild() {
797
+ applyGeometry();
798
+ buildLines();
799
+ renderRadial();
800
+ }
801
+ function requestRebuild() {
802
+ window.cancelAnimationFrame(resizeFrame);
803
+ resizeFrame = window.requestAnimationFrame(rebuild);
804
+ }
805
+ if (text.trim()) {
806
+ rebuild();
807
+ } else {
808
+ discElement.querySelectorAll(".radialText__line").forEach((element) => element.remove());
809
+ setScrollHeightVh(resolvedConfig.minScrollHeightVh);
810
+ }
811
+ window.addEventListener("scroll", requestConveyorUpdate, { passive: true });
812
+ window.addEventListener("resize", requestRebuild);
813
+ return () => {
814
+ disposed = true;
815
+ window.removeEventListener("scroll", requestConveyorUpdate);
816
+ window.removeEventListener("resize", requestRebuild);
817
+ window.cancelAnimationFrame(tickFrame);
818
+ window.cancelAnimationFrame(resizeFrame);
819
+ discElement.querySelectorAll(".radialText__line").forEach((element) => element.remove());
820
+ };
821
+ }, [
822
+ geometry,
823
+ layout,
824
+ loop,
825
+ shape,
826
+ text,
827
+ typography,
828
+ resolvedConfig
829
+ ]);
830
+ return /* @__PURE__ */ jsxs("div", { className: rootClassName, style, ref: rootRef, children: [
831
+ /* @__PURE__ */ jsx(
832
+ "div",
833
+ {
834
+ className: "radialText__disc",
835
+ ref: discRef,
836
+ style: {
837
+ ...discBg != null ? { background: discBg } : {},
838
+ "--radial-text-color": textColor
839
+ },
840
+ children: /* @__PURE__ */ jsxs("svg", { className: "radialText__guides", ref: guideSvgRef, "aria-hidden": "true", children: [
841
+ /* @__PURE__ */ jsx("path", { className: "radialText__guidePath radialText__guidePath--outer", ref: outerGuidePathRef }),
842
+ /* @__PURE__ */ jsx("path", { className: "radialText__guidePath", ref: guidePathRef })
843
+ ] })
844
+ }
845
+ ),
846
+ /* @__PURE__ */ jsx("div", { className: "radialText__scrollSpacer", "aria-hidden": "true" })
847
+ ] });
848
+ }
849
+ function lineTransform(line) {
850
+ const pivotAdjY = line.y - line.fontSize / 2;
851
+ return `translate(${line.x}px, ${pivotAdjY}px) rotate(${line.angleDeg}deg)`;
852
+ }
853
+ function resolveRadialTextConfig(geometry, layout, typography) {
854
+ const fontFamily = typography?.fontFamily ?? DEFAULT_TYPOGRAPHY.fontFamily;
855
+ const bodySize = Math.max(1, typography?.bodySize ?? DEFAULT_TYPOGRAPHY.bodySize);
856
+ const headingSize = Math.max(1, typography?.headingSize ?? DEFAULT_TYPOGRAPHY.headingSize);
857
+ const quoteSize = Math.max(1, typography?.quoteSize ?? DEFAULT_TYPOGRAPHY.quoteSize);
858
+ const bodyWeight = Math.max(1, typography?.bodyWeight ?? DEFAULT_TYPOGRAPHY.bodyWeight);
859
+ const headingWeight = Math.max(1, typography?.headingWeight ?? DEFAULT_TYPOGRAPHY.headingWeight);
860
+ return {
861
+ geometry: {
862
+ stadium: { ...DEFAULT_GEOMETRY2.stadium, ...geometry?.stadium },
863
+ ellipse: { ...DEFAULT_GEOMETRY2.ellipse, ...geometry?.ellipse },
864
+ spiral: { ...DEFAULT_GEOMETRY2.spiral, ...geometry?.spiral },
865
+ wave: { ...DEFAULT_GEOMETRY2.wave, ...geometry?.wave },
866
+ blob: { ...DEFAULT_GEOMETRY2.blob, ...geometry?.blob },
867
+ svgPath: { ...DEFAULT_GEOMETRY2.svgPath, ...geometry?.svgPath }
868
+ },
869
+ typography: {
870
+ paragraph: { family: fontFamily, fontSize: bodySize, weight: bodyWeight },
871
+ "list-item": { family: fontFamily, fontSize: bodySize, weight: bodyWeight },
872
+ "list-continuation": { family: fontFamily, fontSize: bodySize, weight: bodyWeight },
873
+ spacer: { family: fontFamily, fontSize: bodySize, weight: bodyWeight },
874
+ separator: { family: fontFamily, fontSize: bodySize, weight: bodyWeight },
875
+ quote: { family: fontFamily, fontSize: quoteSize, weight: bodyWeight, italic: true },
876
+ heading: { family: fontFamily, fontSize: headingSize, weight: headingWeight }
877
+ },
878
+ align: layout?.align ?? DEFAULT_LAYOUT.align,
879
+ textInset: Math.max(0, layout?.textInset ?? DEFAULT_LAYOUT.textInset),
880
+ lineSpacing: Math.max(4, layout?.lineSpacing ?? DEFAULT_LAYOUT.lineSpacing),
881
+ minScrollHeightVh: Math.max(100, layout?.minScrollHeightVh ?? DEFAULT_LAYOUT.minScrollHeightVh),
882
+ linesPerViewport: Math.max(1, layout?.linesPerViewport ?? DEFAULT_LAYOUT.linesPerViewport)
883
+ };
884
+ }
885
+ function createStadiumShape(width, height, config) {
886
+ return createStadiumTrack(width, height, config.geometry.stadium);
887
+ }
888
+ function createEllipseShape(width, height, config) {
889
+ return createEllipseTrack(width, height, config.geometry.ellipse);
890
+ }
891
+ function createSpiralShape(width, height, config) {
892
+ return createSpiralTrack(width, height, config.geometry.spiral);
893
+ }
894
+ function createWaveShape(width, height, config) {
895
+ return createWaveTrack(width, height, config.geometry.wave);
896
+ }
897
+ function createBlobShape(width, height, config) {
898
+ return createBlobTrack(width, height, config.geometry.blob);
899
+ }
900
+ function createSvgPathShape(width, height, config) {
901
+ return createSvgPolylineTrack(width, height, config.geometry.svgPath);
902
+ }
903
+
904
+ // src/lib/export-disc.ts
905
+ import html2canvas from "html2canvas";
906
+ var GUIDES_ACTIVE_CLASS = "radialText--guides";
907
+ async function exportDiscAsPng(discEl, rootEl, filename = "radial-text.png") {
908
+ const hadGuides = rootEl.classList.contains(GUIDES_ACTIVE_CLASS);
909
+ if (hadGuides) {
910
+ rootEl.classList.remove(GUIDES_ACTIVE_CLASS);
911
+ }
912
+ try {
913
+ const canvas = await html2canvas(discEl, {
914
+ useCORS: true,
915
+ scale: window.devicePixelRatio ?? 1,
916
+ windowWidth: window.innerWidth,
917
+ windowHeight: window.innerHeight,
918
+ width: discEl.offsetWidth,
919
+ height: discEl.offsetHeight,
920
+ x: 0,
921
+ y: 0,
922
+ scrollX: 0,
923
+ scrollY: 0,
924
+ foreignObjectRendering: false,
925
+ logging: false
926
+ });
927
+ const a = document.createElement("a");
928
+ a.href = canvas.toDataURL("image/png");
929
+ a.download = filename;
930
+ a.click();
931
+ a.remove();
932
+ } finally {
933
+ if (hadGuides) {
934
+ rootEl.classList.add(GUIDES_ACTIVE_CLASS);
935
+ }
936
+ }
937
+ }
938
+
939
+ // src/lib/export-disc-svg.ts
940
+ function exportDiscAsSvg(discEl, filename = "radial-text.svg") {
941
+ const w = discEl.offsetWidth;
942
+ const h = discEl.offsetHeight;
943
+ const cx = w / 2;
944
+ const cy = h / 2;
945
+ const bgColor = discEl.style.background.trim() || "#f3f4f2";
946
+ const textEls = [];
947
+ discEl.querySelectorAll(".radialText__line").forEach((el) => {
948
+ if (el.style.visibility === "hidden" || !el.textContent) return;
949
+ const { tx, ty, rot } = parseTransform(el.style.transform);
950
+ const fontSize = parseFloat(el.style.fontSize) || 9;
951
+ const ax = (cx + tx).toFixed(2);
952
+ const ay = (cy + ty + fontSize / 2).toFixed(2);
953
+ const fill = window.getComputedStyle(el).color;
954
+ const px = parseFloat(el.style.paddingLeft) || 0;
955
+ textEls.push(
956
+ `<g transform="translate(${ax},${ay}) rotate(${rot.toFixed(2)})"><text x="${px}" dominant-baseline="middle" font-size="${fontSize}" font-family="${esc(el.style.fontFamily)}" font-weight="${el.style.fontWeight}" font-style="${el.style.fontStyle || "normal"}" fill="${fill}">${esc(el.textContent)}</text></g>`
957
+ );
958
+ });
959
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">
960
+ <rect width="${w}" height="${h}" fill="${esc(bgColor)}"/>
961
+ ` + textEls.join("\n") + `
962
+ </svg>`;
963
+ const a = document.createElement("a");
964
+ a.href = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
965
+ a.download = filename;
966
+ a.click();
967
+ a.remove();
968
+ }
969
+ function parseTransform(t) {
970
+ const tr = t.match(/translate\(([^,]+)px,\s*([^)]+)px\)/);
971
+ const ro = t.match(/rotate\(([^)]+)deg\)/);
972
+ return {
973
+ tx: tr ? parseFloat(tr[1]) : 0,
974
+ ty: tr ? parseFloat(tr[2]) : 0,
975
+ rot: ro ? parseFloat(ro[1]) : 0
976
+ };
977
+ }
978
+ function esc(s) {
979
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
980
+ }
981
+ export {
982
+ RadialText,
983
+ exportDiscAsPng,
984
+ exportDiscAsSvg
985
+ };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@pswaqtch/around",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "files": ["dist"],
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "import": "./dist/index.js",
15
+ "types": "./dist/index.d.ts"
16
+ },
17
+ "./style.css": "./dist/index.css"
18
+ },
19
+ "scripts": {
20
+ "dev": "vite --host 127.0.0.1",
21
+ "build": "tsup",
22
+ "build:demo": "tsc && vite build",
23
+ "typecheck": "tsc --noEmit"
24
+ },
25
+ "peerDependencies": {
26
+ "react": ">=18",
27
+ "react-dom": ">=18"
28
+ },
29
+ "dependencies": {
30
+ "@chenglou/pretext": "^0.0.7",
31
+ "html2canvas": "^1.4.1"
32
+ },
33
+ "devDependencies": {
34
+ "@types/html2canvas": "^1.0.0",
35
+ "@types/react": "^19.2.15",
36
+ "@types/react-dom": "^19.2.3",
37
+ "react": "^19.2.6",
38
+ "react-dom": "^19.2.6",
39
+ "tsup": "^8.5.1",
40
+ "typescript": "^6.0.3",
41
+ "vite": "^7.2.4"
42
+ }
43
+ }