@leviosa-ai/detail-page-editor 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/components/cardnews/color-input.tsx +380 -0
- package/components/detail-page/ai-generate-panel.tsx +763 -0
- package/components/detail-page/annotation-canvas.tsx +860 -0
- package/components/detail-page/annotation-dialog.tsx +397 -0
- package/components/detail-page/bubble-tail-overlay.tsx +209 -0
- package/components/detail-page/canvas-context-menu.tsx +234 -0
- package/components/detail-page/canvas-observer.tsx +68 -0
- package/components/detail-page/canvas-overlay-host.tsx +44 -0
- package/components/detail-page/canvas-store-facade.ts +51 -0
- package/components/detail-page/chart-inspector.tsx +531 -0
- package/components/detail-page/design-reference-picker.tsx +383 -0
- package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
- package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
- package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
- package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
- package/components/detail-page/detail-page-charts-panel.tsx +104 -0
- package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
- package/components/detail-page/detail-page-download-dialog.tsx +524 -0
- package/components/detail-page/detail-page-editor.tsx +974 -0
- package/components/detail-page/detail-page-elements-panel.tsx +117 -0
- package/components/detail-page/detail-page-font-picker.tsx +231 -0
- package/components/detail-page/detail-page-history-buttons.tsx +87 -0
- package/components/detail-page/detail-page-host-context.tsx +465 -0
- package/components/detail-page/detail-page-icons-panel.tsx +251 -0
- package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
- package/components/detail-page/detail-page-layers-panel.tsx +618 -0
- package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
- package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
- package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
- package/components/detail-page/detail-page-pages-panel.tsx +299 -0
- package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
- package/components/detail-page/detail-page-photos-panel.tsx +257 -0
- package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
- package/components/detail-page/detail-page-qr-panel.tsx +137 -0
- package/components/detail-page/detail-page-references-panel.tsx +166 -0
- package/components/detail-page/detail-page-selection.ts +44 -0
- package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
- package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
- package/components/detail-page/detail-page-tables-panel.tsx +106 -0
- package/components/detail-page/detail-page-text-panel.tsx +179 -0
- package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
- package/components/detail-page/edit-quota-ui.tsx +150 -0
- package/components/detail-page/editor-history.ts +56 -0
- package/components/detail-page/editor-hotkeys.tsx +165 -0
- package/components/detail-page/element-recents-strip.tsx +105 -0
- package/components/detail-page/element-rects.ts +82 -0
- package/components/detail-page/fill-control.tsx +261 -0
- package/components/detail-page/find-replace-panel.tsx +252 -0
- package/components/detail-page/generation-progress-fill.tsx +57 -0
- package/components/detail-page/gif-animator.tsx +184 -0
- package/components/detail-page/gif-effect-picker.tsx +136 -0
- package/components/detail-page/group-drill-in.tsx +219 -0
- package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
- package/components/detail-page/hover-highlight-overlay.tsx +111 -0
- package/components/detail-page/hovered-layer.ts +34 -0
- package/components/detail-page/inspector-controls.tsx +175 -0
- package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
- package/components/detail-page/nested-selection-overlay.tsx +364 -0
- package/components/detail-page/panel-search-input.tsx +49 -0
- package/components/detail-page/prompt-edit-panel.tsx +146 -0
- package/components/detail-page/section-height-handle.tsx +276 -0
- package/components/detail-page/section-reauthor-controller.tsx +210 -0
- package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
- package/components/detail-page/spec-resize-absorber.tsx +149 -0
- package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
- package/components/detail-page/table-canvas-overlay.tsx +707 -0
- package/components/detail-page/table-inspector.tsx +586 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/command.tsx +175 -0
- package/components/ui/dialog.tsx +167 -0
- package/components/ui/label.tsx +24 -0
- package/components/ui/select.tsx +190 -0
- package/components/ui/slider.tsx +63 -0
- package/components/ui/switch.tsx +35 -0
- package/components/ui/tooltip.tsx +57 -0
- package/config/detail-page-bundle-font-tags.json +39 -0
- package/config/detail-page-fonts.json +373 -0
- package/index.ts +24 -0
- package/lib/cardnews/font-loader.ts +306 -0
- package/lib/detail-page/animation-sniff.ts +101 -0
- package/lib/detail-page/asset-bytes-url.ts +70 -0
- package/lib/detail-page/basic-shapes.ts +603 -0
- package/lib/detail-page/canvas-menu.ts +182 -0
- package/lib/detail-page/chart/defaults.ts +283 -0
- package/lib/detail-page/chart/format.ts +66 -0
- package/lib/detail-page/chart/normalize.ts +149 -0
- package/lib/detail-page/chart/palette.ts +85 -0
- package/lib/detail-page/chart/paste.ts +82 -0
- package/lib/detail-page/chart/preview.ts +78 -0
- package/lib/detail-page/chart/render.ts +188 -0
- package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
- package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
- package/lib/detail-page/chart/renderers/donut.ts +186 -0
- package/lib/detail-page/chart/renderers/gauge.ts +96 -0
- package/lib/detail-page/chart/renderers/line.ts +143 -0
- package/lib/detail-page/chart/renderers/shared.ts +263 -0
- package/lib/detail-page/chart/renderers/stack.ts +121 -0
- package/lib/detail-page/chart/sync.ts +92 -0
- package/lib/detail-page/chart/types.ts +145 -0
- package/lib/detail-page/css-color.ts +34 -0
- package/lib/detail-page/data-gif-payload.ts +167 -0
- package/lib/detail-page/design-reference.ts +244 -0
- package/lib/detail-page/distribute.ts +89 -0
- package/lib/detail-page/editor-routes.ts +10 -0
- package/lib/detail-page/element-recents.ts +167 -0
- package/lib/detail-page/find-replace.ts +120 -0
- package/lib/detail-page/format-painter.ts +159 -0
- package/lib/detail-page/gif-frames.ts +216 -0
- package/lib/detail-page/gif-timeline.ts +65 -0
- package/lib/detail-page/group-action.ts +62 -0
- package/lib/detail-page/hangul-roman.ts +58 -0
- package/lib/detail-page/icon-curated.ts +290 -0
- package/lib/detail-page/icon-fuzzy.ts +250 -0
- package/lib/detail-page/icon-keywords.generated.ts +3766 -0
- package/lib/detail-page/icon-keywords.ko.ts +524 -0
- package/lib/detail-page/icon-search.ts +257 -0
- package/lib/detail-page/icons.ts +67 -0
- package/lib/detail-page/image-credit.ts +121 -0
- package/lib/detail-page/image-data-uri.ts +29 -0
- package/lib/detail-page/insert-image.ts +75 -0
- package/lib/detail-page/insert-shape.ts +49 -0
- package/lib/detail-page/korean-dictionary.ts +187 -0
- package/lib/detail-page/layer-move.ts +257 -0
- package/lib/detail-page/new-product-draft-store.ts +105 -0
- package/lib/detail-page/new-product-draft.ts +283 -0
- package/lib/detail-page/personal-images-refresh.ts +30 -0
- package/lib/detail-page/product-source.ts +587 -0
- package/lib/detail-page/qr-code.ts +214 -0
- package/lib/detail-page/reference-image.ts +103 -0
- package/lib/detail-page/reference-upload.ts +181 -0
- package/lib/detail-page/replace-page.ts +35 -0
- package/lib/detail-page/replace-with-gif.ts +251 -0
- package/lib/detail-page/section-height.ts +131 -0
- package/lib/detail-page/section-reauthor-bus.ts +61 -0
- package/lib/detail-page/shape-to-image.ts +219 -0
- package/lib/detail-page/spec-group/clipboard.ts +35 -0
- package/lib/detail-page/spec-group/sync.ts +377 -0
- package/lib/detail-page/spec-group/text-metrics.ts +48 -0
- package/lib/detail-page/stock-photos.ts +88 -0
- package/lib/detail-page/svg-colors.ts +74 -0
- package/lib/detail-page/table/defaults.ts +344 -0
- package/lib/detail-page/table/edit.ts +312 -0
- package/lib/detail-page/table/harvest.ts +103 -0
- package/lib/detail-page/table/normalize.ts +125 -0
- package/lib/detail-page/table/paste.ts +49 -0
- package/lib/detail-page/table/preview.ts +66 -0
- package/lib/detail-page/table/render.ts +388 -0
- package/lib/detail-page/table/sync.ts +107 -0
- package/lib/detail-page/table/types.ts +102 -0
- package/lib/detail-page/text-gif-layout.ts +245 -0
- package/lib/detail-page/text-gif-payload.ts +67 -0
- package/lib/detail-page/text-presets.ts +230 -0
- package/lib/detail-page/use-fake-progress.ts +67 -0
- package/lib/detail-page/z-order.ts +82 -0
- package/lib/detail-page-canvas/bubble-path.ts +190 -0
- package/lib/detail-page-canvas/canvas-key.ts +22 -0
- package/lib/detail-page-canvas/clip-rect.ts +65 -0
- package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
- package/lib/detail-page-canvas/editor-fonts.ts +161 -0
- package/lib/detail-page-canvas/export/ai.ts +413 -0
- package/lib/detail-page-canvas/export/color.ts +35 -0
- package/lib/detail-page-canvas/export/document-model.ts +78 -0
- package/lib/detail-page-canvas/export/export-files.ts +290 -0
- package/lib/detail-page-canvas/export/font-subset.ts +264 -0
- package/lib/detail-page-canvas/export/gif-export.ts +310 -0
- package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
- package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
- package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
- package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
- package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
- package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
- package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
- package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
- package/lib/detail-page-canvas/export/psd.ts +354 -0
- package/lib/detail-page-canvas/export/raster.ts +255 -0
- package/lib/detail-page-canvas/export/svg.ts +479 -0
- package/lib/detail-page-canvas/export/text-layout.ts +142 -0
- package/lib/detail-page-canvas/font-catalog.ts +177 -0
- package/lib/detail-page-canvas/font-tags.ts +47 -0
- package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
- package/lib/detail-page-canvas/konva-fallback.ts +477 -0
- package/lib/detail-page-canvas/text-background-plan.ts +124 -0
- package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
- package/lib/utils.ts +10 -0
- package/package.json +68 -0
- package/types/commerce.ts +171 -0
- package/types/detail-page-api.ts +200 -0
- package/types/detail-page-canvas.ts +20 -0
- package/types/fontkit.d.ts +42 -0
- package/types/gifenc.d.ts +51 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 렌더러들이 공유하는 자잘한 것들.
|
|
3
|
+
*
|
|
4
|
+
* 여기서 만드는 props는 **Canvas 기본 요소**의 것뿐이다(``figure``/``text``/``svg``).
|
|
5
|
+
* 새 타입을 만들지 않는 게 이 설계의 전부다 — 내보내기 4경로가 전부 ``el.type``
|
|
6
|
+
* switch라서, 모르는 타입은 조용히 빠진다.
|
|
7
|
+
*
|
|
8
|
+
* ``figure``의 ``subType``도 내보내기가 아는 것만 쓴다: 기본 ``rect``와
|
|
9
|
+
* ``circle``/``ellipse``(``export/svg.ts:220``, ``export/ai.ts:128``, ``export/raster.ts:65``).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { encodeSvgDataUri } from "../../../detail-page-canvas/export/svg";
|
|
13
|
+
// 글자 치수는 표와 같은 자를 쓴다(``spec-group/text-metrics``).
|
|
14
|
+
import {
|
|
15
|
+
LINE_HEIGHT,
|
|
16
|
+
estimateTextWidth,
|
|
17
|
+
textHeight,
|
|
18
|
+
} from "../../spec-group/text-metrics";
|
|
19
|
+
|
|
20
|
+
import type { ChartNode, ChartStyle } from "../types";
|
|
21
|
+
|
|
22
|
+
export { LINE_HEIGHT, estimateTextWidth, textHeight };
|
|
23
|
+
|
|
24
|
+
export function rectNode(
|
|
25
|
+
key: string,
|
|
26
|
+
box: { x: number; y: number; width: number; height: number },
|
|
27
|
+
fill: string,
|
|
28
|
+
cornerRadius = 0,
|
|
29
|
+
): ChartNode {
|
|
30
|
+
return {
|
|
31
|
+
key,
|
|
32
|
+
props: {
|
|
33
|
+
type: "figure",
|
|
34
|
+
subType: "rect",
|
|
35
|
+
x: Math.round(box.x),
|
|
36
|
+
y: Math.round(box.y),
|
|
37
|
+
width: Math.max(0, Math.round(box.width)),
|
|
38
|
+
height: Math.max(0, Math.round(box.height)),
|
|
39
|
+
fill,
|
|
40
|
+
cornerRadius: Math.round(cornerRadius),
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function textNode(
|
|
46
|
+
key: string,
|
|
47
|
+
box: { x: number; y: number; width: number },
|
|
48
|
+
text: string,
|
|
49
|
+
{
|
|
50
|
+
style,
|
|
51
|
+
fontSize,
|
|
52
|
+
fill,
|
|
53
|
+
align,
|
|
54
|
+
fontWeight,
|
|
55
|
+
}: {
|
|
56
|
+
style: ChartStyle;
|
|
57
|
+
fontSize: number;
|
|
58
|
+
fill: string;
|
|
59
|
+
align: "left" | "center" | "right";
|
|
60
|
+
fontWeight: "400" | "500" | "700";
|
|
61
|
+
},
|
|
62
|
+
): ChartNode {
|
|
63
|
+
return {
|
|
64
|
+
key,
|
|
65
|
+
props: {
|
|
66
|
+
type: "text",
|
|
67
|
+
x: Math.round(box.x),
|
|
68
|
+
y: Math.round(box.y),
|
|
69
|
+
width: Math.max(1, Math.round(box.width)),
|
|
70
|
+
height: textHeight(fontSize),
|
|
71
|
+
text,
|
|
72
|
+
fontSize,
|
|
73
|
+
fontFamily: style.fontFamily,
|
|
74
|
+
fontWeight,
|
|
75
|
+
fill,
|
|
76
|
+
align,
|
|
77
|
+
lineHeight: LINE_HEIGHT,
|
|
78
|
+
verticalAlign: "top",
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function circleNode(
|
|
84
|
+
key: string,
|
|
85
|
+
center: { x: number; y: number },
|
|
86
|
+
radius: number,
|
|
87
|
+
fill: string,
|
|
88
|
+
): ChartNode {
|
|
89
|
+
return {
|
|
90
|
+
key,
|
|
91
|
+
props: {
|
|
92
|
+
type: "figure",
|
|
93
|
+
subType: "circle",
|
|
94
|
+
x: Math.round(center.x - radius),
|
|
95
|
+
y: Math.round(center.y - radius),
|
|
96
|
+
width: Math.round(radius * 2),
|
|
97
|
+
height: Math.round(radius * 2),
|
|
98
|
+
fill,
|
|
99
|
+
cornerRadius: 0,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 곡선 기하(도넛·게이지·꺾은선)를 담는 ``svg`` 요소.
|
|
106
|
+
*
|
|
107
|
+
* **텍스트는 절대 여기에 넣지 않는다.** 스톡 편집기의 svg 요소는 ``<img>``로 래스터화되고,
|
|
108
|
+
* ``<img>``로 로드된 SVG는 문서의 ``@font-face``를 못 받아 시스템 폰트로 떨어진다.
|
|
109
|
+
* 그래서 글자는 언제나 네이티브 ``text`` 요소로 따로 얹는다.
|
|
110
|
+
*
|
|
111
|
+
* ``xmlns``는 반드시 넣는다 — 없으면 편집기의 색 치환이 파싱에서 깨진다.
|
|
112
|
+
*/
|
|
113
|
+
export function svgNode(
|
|
114
|
+
key: string,
|
|
115
|
+
box: { x: number; y: number; width: number; height: number },
|
|
116
|
+
body: string,
|
|
117
|
+
): ChartNode {
|
|
118
|
+
const width = Math.max(1, Math.round(box.width));
|
|
119
|
+
const height = Math.max(1, Math.round(box.height));
|
|
120
|
+
const markup = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${body}</svg>`;
|
|
121
|
+
return {
|
|
122
|
+
key,
|
|
123
|
+
props: {
|
|
124
|
+
type: "svg",
|
|
125
|
+
src: encodeSvgDataUri(markup),
|
|
126
|
+
x: Math.round(box.x),
|
|
127
|
+
y: Math.round(box.y),
|
|
128
|
+
width,
|
|
129
|
+
height,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 도(degree) 위의 점. 0도는 3시 방향이라 위쪽에서 시작하려면 -90도로 넘긴다. */
|
|
135
|
+
export function polar(
|
|
136
|
+
cx: number,
|
|
137
|
+
cy: number,
|
|
138
|
+
radius: number,
|
|
139
|
+
degrees: number,
|
|
140
|
+
): { x: number; y: number } {
|
|
141
|
+
const rad = (degrees * Math.PI) / 180;
|
|
142
|
+
return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function round(value: number): string {
|
|
146
|
+
return (Math.round(value * 100) / 100).toString();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 고리 조각(annulus sector)의 path.
|
|
151
|
+
*
|
|
152
|
+
* 한 바퀴를 다 채우면 SVG 호가 시작점과 끝점이 같아져 아무것도 안 그려진다.
|
|
153
|
+
* 그래서 359.9도에서 자른다 — 눈으로는 완전한 고리와 구분되지 않는다.
|
|
154
|
+
*/
|
|
155
|
+
export function ringSlicePath(
|
|
156
|
+
cx: number,
|
|
157
|
+
cy: number,
|
|
158
|
+
outer: number,
|
|
159
|
+
inner: number,
|
|
160
|
+
startDeg: number,
|
|
161
|
+
endDeg: number,
|
|
162
|
+
): string {
|
|
163
|
+
const sweep = Math.min(359.9, Math.max(0, endDeg - startDeg));
|
|
164
|
+
if (sweep <= 0) return "";
|
|
165
|
+
const end = startDeg + sweep;
|
|
166
|
+
const large = sweep > 180 ? 1 : 0;
|
|
167
|
+
const o0 = polar(cx, cy, outer, startDeg);
|
|
168
|
+
const o1 = polar(cx, cy, outer, end);
|
|
169
|
+
const i1 = polar(cx, cy, inner, end);
|
|
170
|
+
const i0 = polar(cx, cy, inner, startDeg);
|
|
171
|
+
return [
|
|
172
|
+
`M ${round(o0.x)} ${round(o0.y)}`,
|
|
173
|
+
`A ${round(outer)} ${round(outer)} 0 ${large} 1 ${round(o1.x)} ${round(o1.y)}`,
|
|
174
|
+
`L ${round(i1.x)} ${round(i1.y)}`,
|
|
175
|
+
`A ${round(inner)} ${round(inner)} 0 ${large} 0 ${round(i0.x)} ${round(i0.y)}`,
|
|
176
|
+
"Z",
|
|
177
|
+
].join(" ");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 점들을 잇는 폴리라인 ``d``. */
|
|
181
|
+
export function polylinePath(points: { x: number; y: number }[]): string {
|
|
182
|
+
return points
|
|
183
|
+
.map((p, i) => `${i === 0 ? "M" : "L"} ${round(p.x)} ${round(p.y)}`)
|
|
184
|
+
.join(" ");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 범례 색 칩의 한 변. */
|
|
188
|
+
export const LEGEND_CHIP = 12;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 시리즈가 여럿일 때 위에 얹는 가로 범례(칩 + 이름).
|
|
192
|
+
*
|
|
193
|
+
* 한 줄에만 놓는다 — 상세페이지 차트에서 시리즈가 서너 개를 넘어가는 일은 거의 없고,
|
|
194
|
+
* 줄바꿈까지 얹으면 높이 계산이 폰트에 의존하게 된다.
|
|
195
|
+
*/
|
|
196
|
+
export function seriesLegend({
|
|
197
|
+
names,
|
|
198
|
+
colors,
|
|
199
|
+
style,
|
|
200
|
+
top,
|
|
201
|
+
}: {
|
|
202
|
+
names: string[];
|
|
203
|
+
colors: string[];
|
|
204
|
+
style: ChartStyle;
|
|
205
|
+
top: number;
|
|
206
|
+
}): { nodes: ChartNode[]; height: number } {
|
|
207
|
+
if (names.length <= 1) return { nodes: [], height: 0 };
|
|
208
|
+
const size = Math.round(style.labelSize * 0.9);
|
|
209
|
+
const rowH = Math.max(textHeight(size), LEGEND_CHIP);
|
|
210
|
+
const nodes: ChartNode[] = [];
|
|
211
|
+
let cursor = 0;
|
|
212
|
+
names.forEach((name, i) => {
|
|
213
|
+
nodes.push(
|
|
214
|
+
rectNode(
|
|
215
|
+
`legend:chip:${i}`,
|
|
216
|
+
{
|
|
217
|
+
x: cursor,
|
|
218
|
+
y: top + (rowH - LEGEND_CHIP) / 2,
|
|
219
|
+
width: LEGEND_CHIP,
|
|
220
|
+
height: LEGEND_CHIP,
|
|
221
|
+
},
|
|
222
|
+
colors[i] ?? style.mutedColor,
|
|
223
|
+
3,
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
const textWidth = Math.max(24, estimateTextWidth(name, size) + 4);
|
|
227
|
+
nodes.push(
|
|
228
|
+
textNode(
|
|
229
|
+
`legend:label:${i}`,
|
|
230
|
+
{
|
|
231
|
+
x: cursor + LEGEND_CHIP + 6,
|
|
232
|
+
y: top + (rowH - textHeight(size)) / 2,
|
|
233
|
+
width: textWidth,
|
|
234
|
+
},
|
|
235
|
+
name,
|
|
236
|
+
{
|
|
237
|
+
style,
|
|
238
|
+
fontSize: size,
|
|
239
|
+
fill: style.labelColor,
|
|
240
|
+
align: "left",
|
|
241
|
+
fontWeight: "400",
|
|
242
|
+
},
|
|
243
|
+
),
|
|
244
|
+
);
|
|
245
|
+
cursor += LEGEND_CHIP + 6 + textWidth + 16;
|
|
246
|
+
});
|
|
247
|
+
return { nodes, height: rowH };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** 막대 모서리는 두께의 절반을 못 넘는다(넘으면 Konva가 알약을 찌그러뜨린다). */
|
|
251
|
+
export function barRadius(style: ChartStyle, thickness: number): number {
|
|
252
|
+
return Math.min(style.cornerRadius, thickness / 2);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 값이 있는데 비율이 0에 가까워 막대가 사라지는 걸 막는 최소 길이.
|
|
257
|
+
*
|
|
258
|
+
* 0과 "아주 작은 값"이 똑같이 안 보이면 차트가 거짓말을 한다.
|
|
259
|
+
*/
|
|
260
|
+
export function visibleLength(length: number, ratio: number): number {
|
|
261
|
+
if (ratio <= 0) return 0;
|
|
262
|
+
return Math.max(2, length);
|
|
263
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 누적 막대 — 항목별 구성(성분 비중, 비용 구성)을 나란히 견준다.
|
|
3
|
+
*
|
|
4
|
+
* 축은 항목별 **합**으로 잡히므로(``resolveChart``) 가장 큰 기둥이 플롯을 꽉 채운다.
|
|
5
|
+
* 값 라벨은 조각마다 붙이지 않고 기둥 위에 합계 하나만 얹는다 — 조각 안에 숫자를 넣으면
|
|
6
|
+
* 얇은 조각에서 글자가 삐져나온다.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { formatChartValue } from "../format";
|
|
10
|
+
import { ratioOf, type ResolvedChart } from "../normalize";
|
|
11
|
+
import type { ChartNode, ChartRender, ChartSpec } from "../types";
|
|
12
|
+
import { barRadius, rectNode, seriesLegend, textHeight, textNode } from "./shared";
|
|
13
|
+
|
|
14
|
+
/** 칸 폭 대비 기둥이 차지하는 비율(명시적으로 더 두꺼운 값을 주면 그쪽을 따른다). */
|
|
15
|
+
const COLUMN_FILL = 0.6;
|
|
16
|
+
|
|
17
|
+
export function renderStack(spec: ChartSpec, resolved: ResolvedChart): ChartRender {
|
|
18
|
+
const { style, options } = spec;
|
|
19
|
+
const width = spec.frame.width;
|
|
20
|
+
const count = Math.max(1, resolved.labels.length);
|
|
21
|
+
const nodes: ChartNode[] = [];
|
|
22
|
+
|
|
23
|
+
const seriesColors = resolved.series.map(
|
|
24
|
+
(_, i) =>
|
|
25
|
+
style.palette[i % Math.max(1, style.palette.length)] ?? style.mutedColor,
|
|
26
|
+
);
|
|
27
|
+
const legend = seriesLegend({
|
|
28
|
+
names: resolved.series.map((s) => s.name),
|
|
29
|
+
colors: seriesColors,
|
|
30
|
+
style,
|
|
31
|
+
top: 0,
|
|
32
|
+
});
|
|
33
|
+
nodes.push(...legend.nodes);
|
|
34
|
+
|
|
35
|
+
const valueH = textHeight(style.valueSize);
|
|
36
|
+
const labelH = textHeight(style.labelSize);
|
|
37
|
+
const headH =
|
|
38
|
+
(legend.height > 0 ? legend.height + style.labelGap : 0) +
|
|
39
|
+
(options.showValue ? valueH + style.labelGap : 0);
|
|
40
|
+
const plotTop = headH;
|
|
41
|
+
const plotBottom = plotTop + style.plotSize;
|
|
42
|
+
|
|
43
|
+
const colWidth = (width - style.gap * (count - 1)) / count;
|
|
44
|
+
const barWidth = Math.min(colWidth, Math.max(style.barSize, colWidth * COLUMN_FILL));
|
|
45
|
+
const radius = barRadius(style, barWidth);
|
|
46
|
+
|
|
47
|
+
resolved.labels.forEach((label, i) => {
|
|
48
|
+
const colX = i * (colWidth + style.gap);
|
|
49
|
+
const barX = colX + (colWidth - barWidth) / 2;
|
|
50
|
+
let total = 0;
|
|
51
|
+
let bottom = plotBottom;
|
|
52
|
+
|
|
53
|
+
if (style.showTrack) {
|
|
54
|
+
nodes.push(
|
|
55
|
+
rectNode(
|
|
56
|
+
`track:${i}`,
|
|
57
|
+
{ x: barX, y: plotTop, width: barWidth, height: style.plotSize },
|
|
58
|
+
style.trackColor,
|
|
59
|
+
radius,
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
resolved.series.forEach((series, s) => {
|
|
65
|
+
const value = series.values[i];
|
|
66
|
+
if (value === null || value <= 0) return;
|
|
67
|
+
total += value;
|
|
68
|
+
const height = style.plotSize * ratioOf(value, resolved.max);
|
|
69
|
+
bottom -= height;
|
|
70
|
+
nodes.push(
|
|
71
|
+
rectNode(
|
|
72
|
+
`bar:${s}:${i}`,
|
|
73
|
+
{ x: barX, y: bottom, width: barWidth, height },
|
|
74
|
+
seriesColors[s],
|
|
75
|
+
// 조각마다 모서리를 둥글리면 기둥이 끊겨 보인다. 맨 위 조각만 둥글린다.
|
|
76
|
+
s === resolved.series.length - 1 ? radius : 0,
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (options.showValue) {
|
|
82
|
+
nodes.push(
|
|
83
|
+
textNode(
|
|
84
|
+
`value:${i}`,
|
|
85
|
+
{ x: colX, y: bottom - style.labelGap - valueH, width: colWidth },
|
|
86
|
+
formatChartValue(total > 0 ? total : null, {
|
|
87
|
+
decimals: options.decimals,
|
|
88
|
+
unit: options.unit,
|
|
89
|
+
}),
|
|
90
|
+
{
|
|
91
|
+
style,
|
|
92
|
+
fontSize: style.valueSize,
|
|
93
|
+
fill: style.valueColor,
|
|
94
|
+
align: "center",
|
|
95
|
+
fontWeight: "700",
|
|
96
|
+
},
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
nodes.push(
|
|
102
|
+
textNode(
|
|
103
|
+
`label:${i}`,
|
|
104
|
+
{ x: colX, y: plotBottom + style.labelGap, width: colWidth },
|
|
105
|
+
label,
|
|
106
|
+
{
|
|
107
|
+
style,
|
|
108
|
+
fontSize: style.labelSize,
|
|
109
|
+
fill: style.labelColor,
|
|
110
|
+
align: "center",
|
|
111
|
+
fontWeight: "400",
|
|
112
|
+
},
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
nodes,
|
|
119
|
+
size: { width, height: Math.round(plotBottom + style.labelGap + labelH) },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 차트 스펙 ↔ Canvas 스토어.
|
|
3
|
+
*
|
|
4
|
+
* 스토어를 만지는 방법은 차트와 표가 똑같아서 ``spec-group/sync``에 한 벌로 있다 —
|
|
5
|
+
* 키 기반 diff, 한 번의 undo, 그룹 base 좌표 0, 자식을 잠그지 않기. **함정 설명은
|
|
6
|
+
* 거기 있다.** 여기서는 "차트를 어떻게 그리고 어떻게 알아보는가"만 정한다.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
detachSpecGroup,
|
|
11
|
+
groupBox,
|
|
12
|
+
insertSpecGroup,
|
|
13
|
+
readSpec,
|
|
14
|
+
syncSpecGroup,
|
|
15
|
+
writeSpec,
|
|
16
|
+
type Box,
|
|
17
|
+
type ElementLike,
|
|
18
|
+
type SpecBinding,
|
|
19
|
+
type StoreLike,
|
|
20
|
+
} from "../spec-group/sync";
|
|
21
|
+
|
|
22
|
+
import { absorbResize, renderChart } from "./render";
|
|
23
|
+
import { CHART_PART, type ChartSpec } from "./types";
|
|
24
|
+
|
|
25
|
+
export type { ElementLike, StoreLike };
|
|
26
|
+
export { documentFontFamily } from "../spec-group/sync";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 저장된 문서에서 오는 값이라 형태를 믿지 않는다 — 최소한의 골격(버전·데이터 배열)만
|
|
30
|
+
* 확인하고, 세부 필드는 렌더러의 기본값 처리에 맡긴다.
|
|
31
|
+
*/
|
|
32
|
+
function parseChartSpec(raw: unknown): ChartSpec | null {
|
|
33
|
+
const spec = raw as Partial<ChartSpec>;
|
|
34
|
+
if (spec.v !== 1) return null;
|
|
35
|
+
if (!spec.kind || !spec.data || !Array.isArray(spec.data.labels)) return null;
|
|
36
|
+
if (!Array.isArray(spec.data.series)) return null;
|
|
37
|
+
return spec as ChartSpec;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const CHART_BINDING: SpecBinding<ChartSpec> = {
|
|
41
|
+
customKey: "chart",
|
|
42
|
+
partKey: CHART_PART,
|
|
43
|
+
defaultName: "차트",
|
|
44
|
+
render: renderChart,
|
|
45
|
+
parse: parseChartSpec,
|
|
46
|
+
absorbResize,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** 그룹에서 차트 스펙을 꺼낸다. 차트가 아니면 ``null``. */
|
|
50
|
+
export function readChartSpec(el: ElementLike | null | undefined): ChartSpec | null {
|
|
51
|
+
return readSpec(CHART_BINDING, el);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 스펙을 그룹에 되쓴다(다른 custom 키는 보존). */
|
|
55
|
+
export function writeChartSpec(el: ElementLike, spec: ChartSpec): void {
|
|
56
|
+
writeSpec(CHART_BINDING, el, spec);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 그룹이 실제로 차지하는 상자. 그룹 모델이 아니라 자식에서 잰다. */
|
|
60
|
+
export function chartBox(group: ElementLike): Box | null {
|
|
61
|
+
return groupBox(group);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type InsertChartOptions = {
|
|
65
|
+
/** 프레임 좌상단(페이지 좌표). 없으면 페이지 가운데. */
|
|
66
|
+
origin?: { x: number; y: number };
|
|
67
|
+
/** 레이어 트리에 뜰 이름. */
|
|
68
|
+
name?: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** 새 차트를 페이지에 놓고 그 그룹을 돌려준다. */
|
|
72
|
+
export function insertChart(
|
|
73
|
+
store: StoreLike,
|
|
74
|
+
spec: ChartSpec,
|
|
75
|
+
options: InsertChartOptions = {},
|
|
76
|
+
): ElementLike | null {
|
|
77
|
+
return insertSpecGroup(CHART_BINDING, store, spec, options);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 스펙이 바뀐 차트를 다시 그린다. 저장된 스펙(실제 frame이 채워진)을 돌려준다. */
|
|
81
|
+
export function syncChartGroup(
|
|
82
|
+
store: StoreLike,
|
|
83
|
+
group: ElementLike,
|
|
84
|
+
next: ChartSpec,
|
|
85
|
+
): ChartSpec {
|
|
86
|
+
return syncSpecGroup(CHART_BINDING, store, group, next);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 차트를 일반 그룹으로 푼다(되돌리기는 제공하지 않는다). */
|
|
90
|
+
export function detachChart(group: ElementLike): void {
|
|
91
|
+
detachSpecGroup(CHART_BINDING, group);
|
|
92
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 상세페이지 편집기 차트의 **데이터 모델**.
|
|
3
|
+
*
|
|
4
|
+
* 차트는 새 element 타입이 아니라 ``group`` 하나이고, 이 스펙은 그 그룹의
|
|
5
|
+
* ``custom.chart``에 통째로 얹힌다. 그래서 저장·불러오기·undo가 공짜로 따라오고,
|
|
6
|
+
* 내보내기(.ai/.psd/.svg/GIF)는 자식이 전부 기존 타입(figure/svg/text)이라 어댑터
|
|
7
|
+
* 없이 그대로 동작한다. 자세한 근거는 ``docs/detail-page-chart-element-plan.md``.
|
|
8
|
+
*
|
|
9
|
+
* 이 파일과 같은 폴더의 모듈은 **canvas를 import하지 않는다**. 순수 계산만 담아야
|
|
10
|
+
* 캔버스 없이 vitest에서 돌릴 수 있다(스토어를 만지는 건 ``sync.ts`` 하나뿐이고,
|
|
11
|
+
* 그것도 구조적 타입으로만 접근한다).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type ChartKind =
|
|
15
|
+
| "bar-h"
|
|
16
|
+
| "bar-v"
|
|
17
|
+
| "donut"
|
|
18
|
+
| "line"
|
|
19
|
+
| "stack"
|
|
20
|
+
| "gauge";
|
|
21
|
+
|
|
22
|
+
/** 종류를 바꿔도 살아남는 정규형 데이터. 렌더러는 이걸 읽기만 한다. */
|
|
23
|
+
export type ChartData = {
|
|
24
|
+
labels: string[];
|
|
25
|
+
series: ChartSeries[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ChartSeries = {
|
|
29
|
+
name: string;
|
|
30
|
+
/** 빈 칸은 ``null``. 렌더러가 0으로 눌러버리면 "값 없음"과 "0"이 구분되지 않는다. */
|
|
31
|
+
values: (number | null)[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type ChartOptions = {
|
|
35
|
+
/** 축 최댓값. ``"auto"``면 데이터에서 뽑는다. */
|
|
36
|
+
max: number | "auto";
|
|
37
|
+
/** 값 뒤에 붙는 단위("%", "ms", "점"). */
|
|
38
|
+
unit: string;
|
|
39
|
+
decimals: number;
|
|
40
|
+
showValue: boolean;
|
|
41
|
+
sort: "none" | "desc" | "asc";
|
|
42
|
+
/**
|
|
43
|
+
* 강조할 항목 인덱스(보통 "우리 제품").
|
|
44
|
+
*
|
|
45
|
+
* 지정하면 그 항목만 팔레트 색, 나머지는 ``mutedColor``가 된다 — 템플릿의
|
|
46
|
+
* ``.bar`` vs ``.bar.alt`` 대비를 그대로 옮긴 것.
|
|
47
|
+
*/
|
|
48
|
+
highlightIndex: number | null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type ChartStyle = {
|
|
52
|
+
/** 항목/시리즈 색. 강조 항목이 지정되면 첫 색만 쓰인다. */
|
|
53
|
+
palette: string[];
|
|
54
|
+
/** 강조 대상이 아닌 항목 색. */
|
|
55
|
+
mutedColor: string;
|
|
56
|
+
/** 막대 트랙(빈 부분) 색. */
|
|
57
|
+
trackColor: string;
|
|
58
|
+
labelColor: string;
|
|
59
|
+
valueColor: string;
|
|
60
|
+
fontFamily: string;
|
|
61
|
+
labelSize: number;
|
|
62
|
+
valueSize: number;
|
|
63
|
+
/** 막대 두께(가로형은 높이, 세로형은 폭). */
|
|
64
|
+
barSize: number;
|
|
65
|
+
/** 항목 사이 간격. */
|
|
66
|
+
gap: number;
|
|
67
|
+
/** 라벨 줄과 막대 사이 간격. */
|
|
68
|
+
labelGap: number;
|
|
69
|
+
/** 세로형·라인의 플롯 높이(가로형은 프레임 폭이 축 길이라 안 쓴다). */
|
|
70
|
+
plotSize: number;
|
|
71
|
+
cornerRadius: number;
|
|
72
|
+
/** 빈 트랙을 깔지 여부. 끄면 막대만 뜬다. */
|
|
73
|
+
showTrack: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* 판 크롬 — 축·눈금선·기둥 밴드·눈금 라벨.
|
|
76
|
+
*
|
|
77
|
+
* 브랜드 상세페이지의 막대그래프는 이걸 전부 갖고 있고, 지금은 그 좌표를 손으로
|
|
78
|
+
* 계산해 통짜 SVG로 굽는다("1% = 6.5px"를 네 곳에 반영). 여기 옵션으로 들어오면
|
|
79
|
+
* 그 환산이 렌더러 한 줄이 된다.
|
|
80
|
+
*
|
|
81
|
+
* ``undefined``면 아무것도 안 그린다 — 기존 프리셋의 렌더 결과가 안 바뀐다.
|
|
82
|
+
* 세로 막대(``bar-v``)만 읽는다.
|
|
83
|
+
*/
|
|
84
|
+
plot?: ChartPlotChrome;
|
|
85
|
+
/**
|
|
86
|
+
* 차트를 감싸는 카드(바탕 + 테두리 + 안여백). 종류와 무관하게 걸린다.
|
|
87
|
+
*
|
|
88
|
+
* ``undefined``면 카드를 안 그린다.
|
|
89
|
+
*/
|
|
90
|
+
card?: ChartCard;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export type ChartPlotChrome = {
|
|
94
|
+
/** 항목 뒤에 까는 옅은 기둥. ``null``이면 안 깐다. */
|
|
95
|
+
bands: string | null;
|
|
96
|
+
/** 가로 눈금선. ``count``는 0선을 뺀 칸 수다. */
|
|
97
|
+
grid: { count: number; color: string; dash: boolean } | null;
|
|
98
|
+
/** 좌·하 축선. */
|
|
99
|
+
axis: { color: string; width: number } | null;
|
|
100
|
+
/** 왼쪽 눈금 라벨. ``unitCap``은 맨 위에 얹는 단위 표기("(%)"). */
|
|
101
|
+
ticks: { color: string; size: number; unitCap: string; gutter: number } | null;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type ChartCard = {
|
|
105
|
+
fill: string;
|
|
106
|
+
stroke: string | null;
|
|
107
|
+
width: number;
|
|
108
|
+
radius: number;
|
|
109
|
+
pad: number;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export type ChartSpec = {
|
|
113
|
+
v: 1;
|
|
114
|
+
kind: ChartKind;
|
|
115
|
+
/**
|
|
116
|
+
* 차트의 논리 크기. ``width``는 입력값이고 ``height``는 렌더 결과로 갱신된다
|
|
117
|
+
* (행이 늘면 자연히 키가 커야 하므로 높이를 사용자가 정하게 두지 않는다).
|
|
118
|
+
*/
|
|
119
|
+
frame: { width: number; height: number };
|
|
120
|
+
data: ChartData;
|
|
121
|
+
options: ChartOptions;
|
|
122
|
+
style: ChartStyle;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 렌더러가 뱉는 요소 하나.
|
|
127
|
+
*
|
|
128
|
+
* ``key``는 재생성 사이에 유지되는 안정 식별자다. sync가 이 키로 기존 자식을 찾아
|
|
129
|
+
* ``set``하기 때문에 요소 id가 보존되고, 그래서 선택·undo·레이어 트리가 흔들리지 않는다.
|
|
130
|
+
* ``props.x``/``props.y``는 **프레임 좌상단 기준 로컬 좌표**이고, 페이지 좌표로 옮기는
|
|
131
|
+
* 건 sync의 몫이다.
|
|
132
|
+
*/
|
|
133
|
+
export type ChartNode = {
|
|
134
|
+
key: string;
|
|
135
|
+
props: Record<string, unknown>;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export type ChartRender = {
|
|
139
|
+
nodes: ChartNode[];
|
|
140
|
+
/** 이 스펙이 실제로 차지하는 크기. ``spec.frame``에 되먹임한다. */
|
|
141
|
+
size: { width: number; height: number };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** 차트 자식임을 표시하는 custom 키(레이어 트리·해제에서 쓴다). */
|
|
145
|
+
export const CHART_PART = "chartPart";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas/Konva 색 문자열을 HEX로 접는다.
|
|
3
|
+
*
|
|
4
|
+
* 편집기는 같은 색을 `#17150f` 로도 `rgb(23, 21, 15)` 로도 돌려준다(요소를 어떻게
|
|
5
|
+
* 만들었는지에 따라 다르다). 반면 텍스트 GIF 같은 백엔드 엔드포인트는 색을 SVG 속성값에
|
|
6
|
+
* 그대로 박기 때문에 짧은 HEX만 받는다. 그래서 보내기 직전에 여기서 정규화한다.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const HEX_RE = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
10
|
+
const RGB_RE =
|
|
11
|
+
/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*(\d*\.?\d+)\s*)?\)$/i;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `value` 를 `#rrggbb`(알파가 있으면 `#rrggbbaa`)로 바꾼다.
|
|
15
|
+
* 아는 형태가 아니면 `fallback` 을 돌려준다 — 백엔드에서 422를 맞느니 기본색이 낫다.
|
|
16
|
+
*/
|
|
17
|
+
export function toHexColor(value: unknown, fallback: string): string {
|
|
18
|
+
if (typeof value !== "string") return fallback;
|
|
19
|
+
|
|
20
|
+
const text = value.trim();
|
|
21
|
+
if (HEX_RE.test(text)) return text.toLowerCase();
|
|
22
|
+
|
|
23
|
+
const match = RGB_RE.exec(text);
|
|
24
|
+
if (!match) return fallback;
|
|
25
|
+
|
|
26
|
+
const channels = [match[1], match[2], match[3]].map(Number);
|
|
27
|
+
if (channels.some((channel) => channel > 255)) return fallback;
|
|
28
|
+
|
|
29
|
+
const hex = `#${channels.map((c) => c.toString(16).padStart(2, "0")).join("")}`;
|
|
30
|
+
if (match[4] === undefined) return hex;
|
|
31
|
+
|
|
32
|
+
const alpha = Math.round(Math.min(1, Math.max(0, Number(match[4]))) * 255);
|
|
33
|
+
return alpha === 255 ? hex : `${hex}${alpha.toString(16).padStart(2, "0")}`;
|
|
34
|
+
}
|