@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 +68 -0
- package/dist/index.d.ts +98 -0
- package/dist/index.js +985 -0
- package/package.json +43 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
+
}
|