@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.
Files changed (190) hide show
  1. package/components/cardnews/color-input.tsx +380 -0
  2. package/components/detail-page/ai-generate-panel.tsx +763 -0
  3. package/components/detail-page/annotation-canvas.tsx +860 -0
  4. package/components/detail-page/annotation-dialog.tsx +397 -0
  5. package/components/detail-page/bubble-tail-overlay.tsx +209 -0
  6. package/components/detail-page/canvas-context-menu.tsx +234 -0
  7. package/components/detail-page/canvas-observer.tsx +68 -0
  8. package/components/detail-page/canvas-overlay-host.tsx +44 -0
  9. package/components/detail-page/canvas-store-facade.ts +51 -0
  10. package/components/detail-page/chart-inspector.tsx +531 -0
  11. package/components/detail-page/design-reference-picker.tsx +383 -0
  12. package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
  13. package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
  14. package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
  15. package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
  16. package/components/detail-page/detail-page-charts-panel.tsx +104 -0
  17. package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
  18. package/components/detail-page/detail-page-download-dialog.tsx +524 -0
  19. package/components/detail-page/detail-page-editor.tsx +974 -0
  20. package/components/detail-page/detail-page-elements-panel.tsx +117 -0
  21. package/components/detail-page/detail-page-font-picker.tsx +231 -0
  22. package/components/detail-page/detail-page-history-buttons.tsx +87 -0
  23. package/components/detail-page/detail-page-host-context.tsx +465 -0
  24. package/components/detail-page/detail-page-icons-panel.tsx +251 -0
  25. package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
  26. package/components/detail-page/detail-page-layers-panel.tsx +618 -0
  27. package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
  28. package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
  29. package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
  30. package/components/detail-page/detail-page-pages-panel.tsx +299 -0
  31. package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
  32. package/components/detail-page/detail-page-photos-panel.tsx +257 -0
  33. package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
  34. package/components/detail-page/detail-page-qr-panel.tsx +137 -0
  35. package/components/detail-page/detail-page-references-panel.tsx +166 -0
  36. package/components/detail-page/detail-page-selection.ts +44 -0
  37. package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
  38. package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
  39. package/components/detail-page/detail-page-tables-panel.tsx +106 -0
  40. package/components/detail-page/detail-page-text-panel.tsx +179 -0
  41. package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
  42. package/components/detail-page/edit-quota-ui.tsx +150 -0
  43. package/components/detail-page/editor-history.ts +56 -0
  44. package/components/detail-page/editor-hotkeys.tsx +165 -0
  45. package/components/detail-page/element-recents-strip.tsx +105 -0
  46. package/components/detail-page/element-rects.ts +82 -0
  47. package/components/detail-page/fill-control.tsx +261 -0
  48. package/components/detail-page/find-replace-panel.tsx +252 -0
  49. package/components/detail-page/generation-progress-fill.tsx +57 -0
  50. package/components/detail-page/gif-animator.tsx +184 -0
  51. package/components/detail-page/gif-effect-picker.tsx +136 -0
  52. package/components/detail-page/group-drill-in.tsx +219 -0
  53. package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
  54. package/components/detail-page/hover-highlight-overlay.tsx +111 -0
  55. package/components/detail-page/hovered-layer.ts +34 -0
  56. package/components/detail-page/inspector-controls.tsx +175 -0
  57. package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
  58. package/components/detail-page/nested-selection-overlay.tsx +364 -0
  59. package/components/detail-page/panel-search-input.tsx +49 -0
  60. package/components/detail-page/prompt-edit-panel.tsx +146 -0
  61. package/components/detail-page/section-height-handle.tsx +276 -0
  62. package/components/detail-page/section-reauthor-controller.tsx +210 -0
  63. package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
  64. package/components/detail-page/spec-resize-absorber.tsx +149 -0
  65. package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
  66. package/components/detail-page/table-canvas-overlay.tsx +707 -0
  67. package/components/detail-page/table-inspector.tsx +586 -0
  68. package/components/ui/button.tsx +64 -0
  69. package/components/ui/command.tsx +175 -0
  70. package/components/ui/dialog.tsx +167 -0
  71. package/components/ui/label.tsx +24 -0
  72. package/components/ui/select.tsx +190 -0
  73. package/components/ui/slider.tsx +63 -0
  74. package/components/ui/switch.tsx +35 -0
  75. package/components/ui/tooltip.tsx +57 -0
  76. package/config/detail-page-bundle-font-tags.json +39 -0
  77. package/config/detail-page-fonts.json +373 -0
  78. package/index.ts +24 -0
  79. package/lib/cardnews/font-loader.ts +306 -0
  80. package/lib/detail-page/animation-sniff.ts +101 -0
  81. package/lib/detail-page/asset-bytes-url.ts +70 -0
  82. package/lib/detail-page/basic-shapes.ts +603 -0
  83. package/lib/detail-page/canvas-menu.ts +182 -0
  84. package/lib/detail-page/chart/defaults.ts +283 -0
  85. package/lib/detail-page/chart/format.ts +66 -0
  86. package/lib/detail-page/chart/normalize.ts +149 -0
  87. package/lib/detail-page/chart/palette.ts +85 -0
  88. package/lib/detail-page/chart/paste.ts +82 -0
  89. package/lib/detail-page/chart/preview.ts +78 -0
  90. package/lib/detail-page/chart/render.ts +188 -0
  91. package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
  92. package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
  93. package/lib/detail-page/chart/renderers/donut.ts +186 -0
  94. package/lib/detail-page/chart/renderers/gauge.ts +96 -0
  95. package/lib/detail-page/chart/renderers/line.ts +143 -0
  96. package/lib/detail-page/chart/renderers/shared.ts +263 -0
  97. package/lib/detail-page/chart/renderers/stack.ts +121 -0
  98. package/lib/detail-page/chart/sync.ts +92 -0
  99. package/lib/detail-page/chart/types.ts +145 -0
  100. package/lib/detail-page/css-color.ts +34 -0
  101. package/lib/detail-page/data-gif-payload.ts +167 -0
  102. package/lib/detail-page/design-reference.ts +244 -0
  103. package/lib/detail-page/distribute.ts +89 -0
  104. package/lib/detail-page/editor-routes.ts +10 -0
  105. package/lib/detail-page/element-recents.ts +167 -0
  106. package/lib/detail-page/find-replace.ts +120 -0
  107. package/lib/detail-page/format-painter.ts +159 -0
  108. package/lib/detail-page/gif-frames.ts +216 -0
  109. package/lib/detail-page/gif-timeline.ts +65 -0
  110. package/lib/detail-page/group-action.ts +62 -0
  111. package/lib/detail-page/hangul-roman.ts +58 -0
  112. package/lib/detail-page/icon-curated.ts +290 -0
  113. package/lib/detail-page/icon-fuzzy.ts +250 -0
  114. package/lib/detail-page/icon-keywords.generated.ts +3766 -0
  115. package/lib/detail-page/icon-keywords.ko.ts +524 -0
  116. package/lib/detail-page/icon-search.ts +257 -0
  117. package/lib/detail-page/icons.ts +67 -0
  118. package/lib/detail-page/image-credit.ts +121 -0
  119. package/lib/detail-page/image-data-uri.ts +29 -0
  120. package/lib/detail-page/insert-image.ts +75 -0
  121. package/lib/detail-page/insert-shape.ts +49 -0
  122. package/lib/detail-page/korean-dictionary.ts +187 -0
  123. package/lib/detail-page/layer-move.ts +257 -0
  124. package/lib/detail-page/new-product-draft-store.ts +105 -0
  125. package/lib/detail-page/new-product-draft.ts +283 -0
  126. package/lib/detail-page/personal-images-refresh.ts +30 -0
  127. package/lib/detail-page/product-source.ts +587 -0
  128. package/lib/detail-page/qr-code.ts +214 -0
  129. package/lib/detail-page/reference-image.ts +103 -0
  130. package/lib/detail-page/reference-upload.ts +181 -0
  131. package/lib/detail-page/replace-page.ts +35 -0
  132. package/lib/detail-page/replace-with-gif.ts +251 -0
  133. package/lib/detail-page/section-height.ts +131 -0
  134. package/lib/detail-page/section-reauthor-bus.ts +61 -0
  135. package/lib/detail-page/shape-to-image.ts +219 -0
  136. package/lib/detail-page/spec-group/clipboard.ts +35 -0
  137. package/lib/detail-page/spec-group/sync.ts +377 -0
  138. package/lib/detail-page/spec-group/text-metrics.ts +48 -0
  139. package/lib/detail-page/stock-photos.ts +88 -0
  140. package/lib/detail-page/svg-colors.ts +74 -0
  141. package/lib/detail-page/table/defaults.ts +344 -0
  142. package/lib/detail-page/table/edit.ts +312 -0
  143. package/lib/detail-page/table/harvest.ts +103 -0
  144. package/lib/detail-page/table/normalize.ts +125 -0
  145. package/lib/detail-page/table/paste.ts +49 -0
  146. package/lib/detail-page/table/preview.ts +66 -0
  147. package/lib/detail-page/table/render.ts +388 -0
  148. package/lib/detail-page/table/sync.ts +107 -0
  149. package/lib/detail-page/table/types.ts +102 -0
  150. package/lib/detail-page/text-gif-layout.ts +245 -0
  151. package/lib/detail-page/text-gif-payload.ts +67 -0
  152. package/lib/detail-page/text-presets.ts +230 -0
  153. package/lib/detail-page/use-fake-progress.ts +67 -0
  154. package/lib/detail-page/z-order.ts +82 -0
  155. package/lib/detail-page-canvas/bubble-path.ts +190 -0
  156. package/lib/detail-page-canvas/canvas-key.ts +22 -0
  157. package/lib/detail-page-canvas/clip-rect.ts +65 -0
  158. package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
  159. package/lib/detail-page-canvas/editor-fonts.ts +161 -0
  160. package/lib/detail-page-canvas/export/ai.ts +413 -0
  161. package/lib/detail-page-canvas/export/color.ts +35 -0
  162. package/lib/detail-page-canvas/export/document-model.ts +78 -0
  163. package/lib/detail-page-canvas/export/export-files.ts +290 -0
  164. package/lib/detail-page-canvas/export/font-subset.ts +264 -0
  165. package/lib/detail-page-canvas/export/gif-export.ts +310 -0
  166. package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
  167. package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
  168. package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
  169. package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
  170. package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
  171. package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
  172. package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
  173. package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
  174. package/lib/detail-page-canvas/export/psd.ts +354 -0
  175. package/lib/detail-page-canvas/export/raster.ts +255 -0
  176. package/lib/detail-page-canvas/export/svg.ts +479 -0
  177. package/lib/detail-page-canvas/export/text-layout.ts +142 -0
  178. package/lib/detail-page-canvas/font-catalog.ts +177 -0
  179. package/lib/detail-page-canvas/font-tags.ts +47 -0
  180. package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
  181. package/lib/detail-page-canvas/konva-fallback.ts +477 -0
  182. package/lib/detail-page-canvas/text-background-plan.ts +124 -0
  183. package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
  184. package/lib/utils.ts +10 -0
  185. package/package.json +68 -0
  186. package/types/commerce.ts +171 -0
  187. package/types/detail-page-api.ts +200 -0
  188. package/types/detail-page-canvas.ts +20 -0
  189. package/types/fontkit.d.ts +42 -0
  190. package/types/gifenc.d.ts +51 -0
@@ -0,0 +1,182 @@
1
+ /**
2
+ * 캔버스 우클릭 메뉴의 항목 구성과 동작. UI와 떼어 놓아 규칙만 테스트한다.
3
+ *
4
+ * 스톡 편집기는 ``canvas/canvas/context-menu``에 자기 메뉴를 갖고 있지만 ``page.js``가
5
+ * ``contextmenu``를 한 번도 안 단다 — 스톡 ``<Workspace>``가 달던 것이고, 우리는 그
6
+ * ``<Workspace>``를 갈아치웠다. 스톡 메뉴를 되살리지 않는 이유는 두 가지다.
7
+ *
8
+ * 1. 블루프린트 팝오버라 캔버스 위 다른 오버레이(전부 Tailwind)와 안 맞는다.
9
+ * 2. 항목이 모자란다. 그룹/해제가 없고, ``useCopyStyle``을 import 해 놓고 실제로는
10
+ * 쓰지 않는다(죽은 코드). 우리 문서는 대부분이 그룹이라 그룹 해제가 꼭 필요하다.
11
+ */
12
+
13
+ import { groupAction } from "./group-action";
14
+ import {
15
+ applyFormat,
16
+ canCopyFormat,
17
+ canPasteFormat,
18
+ copyFormat,
19
+ heldFormat,
20
+ holdFormat,
21
+ } from "./format-painter";
22
+ import { canMoveZ, moveZ, zOrderOf, type ZOrderElement } from "./z-order";
23
+
24
+ export type CanvasMenuAction =
25
+ | "duplicate"
26
+ | "delete"
27
+ | "lock"
28
+ | "unlock"
29
+ | "copyFormat"
30
+ | "pasteFormat"
31
+ | "front"
32
+ | "forward"
33
+ | "backward"
34
+ | "back"
35
+ | "group"
36
+ | "ungroup";
37
+
38
+ export type CanvasMenuItem = {
39
+ action: CanvasMenuAction;
40
+ disabled: boolean;
41
+ /** 이 항목 **위에** 구분선을 긋는다. */
42
+ separated?: boolean;
43
+ };
44
+
45
+ export type MenuElement = ZOrderElement & {
46
+ type?: string;
47
+ locked?: boolean;
48
+ clone?: (attrs?: Record<string, unknown>, opts?: { skipSelect?: boolean }) => unknown;
49
+ set?: (props: Record<string, unknown>) => void;
50
+ };
51
+
52
+ export type CanvasMenuStore = {
53
+ selectedElements?: MenuElement[];
54
+ selectedElementsIds?: string[];
55
+ pages?: Array<{ id: string; children?: Array<{ id: string; type?: string }> }>;
56
+ deleteElements?: (ids: string[]) => void;
57
+ groupElements?: (ids: string[]) => void;
58
+ ungroupElements?: (ids: string[]) => void;
59
+ history?: { startTransaction?: () => void; endTransaction?: () => void };
60
+ };
61
+
62
+ /** 선택 요소가 전부 잠겨 있는가 — 메뉴에 잠금 대신 잠금 해제를 띄운다. */
63
+ export function allLocked(els: ReadonlyArray<MenuElement>): boolean {
64
+ return els.length > 0 && els.every((el) => el.locked === true);
65
+ }
66
+
67
+ /**
68
+ * 지금 선택에 대해 띄울 항목들. 선택이 없으면 빈 배열 = 메뉴를 아예 안 연다.
69
+ *
70
+ * 순서는 z-order를 **단일 선택일 때만** 낸다. 여럿을 한꺼번에 "앞으로" 보내면 서로의
71
+ * 상대 순서가 어떻게 되어야 하는지가 모호하고, 우측 패널도 같은 이유로 단일 전용이다.
72
+ */
73
+ export function canvasMenuItems(store: CanvasMenuStore): CanvasMenuItem[] {
74
+ const els = store.selectedElements ?? [];
75
+ if (els.length === 0) return [];
76
+
77
+ const locked = allLocked(els);
78
+ const anyLocked = els.some((el) => el.locked === true);
79
+ const single = els.length === 1 ? els[0] : null;
80
+ const order = single ? zOrderOf(single) : null;
81
+ const group = groupAction(store, false);
82
+ const ungroup = groupAction(store, true);
83
+
84
+ const items: CanvasMenuItem[] = [
85
+ // 잠긴 요소를 복제하면 잠긴 사본이 생겨 만질 수도 지울 수도 없다.
86
+ { action: "duplicate", disabled: anyLocked },
87
+ { action: locked ? "unlock" : "lock", disabled: false },
88
+ { action: "delete", disabled: anyLocked },
89
+ // 서식은 하나에서 떠서 여럿에 먹인다 — 복사는 단일 선택일 때만.
90
+ { action: "copyFormat", disabled: !single || !canCopyFormat(single), separated: true },
91
+ {
92
+ action: "pasteFormat",
93
+ disabled: anyLocked || !canPasteFormat(heldFormat(), els),
94
+ },
95
+ { action: "front", disabled: !canMoveZ(order, "front"), separated: true },
96
+ { action: "forward", disabled: !canMoveZ(order, "forward") },
97
+ { action: "backward", disabled: !canMoveZ(order, "backward") },
98
+ { action: "back", disabled: !canMoveZ(order, "back") },
99
+ ];
100
+
101
+ // 그룹/해제는 해당될 때만 낸다 — 늘 회색으로 떠 있으면 메뉴만 길어진다.
102
+ if (group?.kind === "group") {
103
+ items.push({ action: "group", disabled: false, separated: true });
104
+ }
105
+ if (ungroup?.kind === "ungroup") {
106
+ items.push({
107
+ action: "ungroup",
108
+ disabled: false,
109
+ separated: group?.kind !== "group",
110
+ });
111
+ }
112
+ return items;
113
+ }
114
+
115
+ /**
116
+ * 한 항목을 실행한다.
117
+ *
118
+ * 여러 요소를 한 번에 만지는 동작(복제·잠금)은 히스토리 트랜잭션으로 묶는다. 안 묶으면
119
+ * ⌘Z를 선택 개수만큼 눌러야 원래대로 돌아간다.
120
+ */
121
+ export function runCanvasMenuAction(
122
+ store: CanvasMenuStore,
123
+ action: CanvasMenuAction,
124
+ ): void {
125
+ const els = store.selectedElements ?? [];
126
+ if (els.length === 0) return;
127
+ const ids = els.map((el) => el.id);
128
+
129
+ const inTransaction = (fn: () => void) => {
130
+ store.history?.startTransaction?.();
131
+ try {
132
+ fn();
133
+ } finally {
134
+ store.history?.endTransaction?.();
135
+ }
136
+ };
137
+
138
+ switch (action) {
139
+ case "duplicate":
140
+ // clone()은 항상 요소의 **페이지**에 추가한다(node-model: e.page.addElement).
141
+ // 그룹 자식의 x/y도 페이지 좌표라, 그룹 안 도형을 복제하면 제자리에 사본이
142
+ // 생기고 최상위로 나온다 — 그룹을 오염시키지 않는 편이 낫다.
143
+ inTransaction(() => {
144
+ for (const el of els) el.clone?.({}, { skipSelect: true });
145
+ });
146
+ return;
147
+ case "delete":
148
+ store.deleteElements?.(ids);
149
+ return;
150
+ case "copyFormat":
151
+ if (els.length === 1) holdFormat(copyFormat(els[0]));
152
+ return;
153
+ case "pasteFormat": {
154
+ const copied = heldFormat();
155
+ // 여럿에 한 번에 먹일 때 ⌘Z 한 번으로 전부 돌아가게 묶는다.
156
+ if (copied) inTransaction(() => applyFormat(copied, els));
157
+ return;
158
+ }
159
+ case "lock":
160
+ case "unlock":
161
+ inTransaction(() => {
162
+ for (const el of els) el.set?.({ locked: action === "lock" });
163
+ });
164
+ return;
165
+ case "front":
166
+ case "forward":
167
+ case "backward":
168
+ case "back":
169
+ if (els.length === 1) moveZ(els[0], action);
170
+ return;
171
+ case "group": {
172
+ const plan = groupAction(store, false);
173
+ if (plan?.kind === "group") store.groupElements?.(plan.ids);
174
+ return;
175
+ }
176
+ case "ungroup": {
177
+ const plan = groupAction(store, true);
178
+ if (plan?.kind === "ungroup") store.ungroupElements?.(plan.ids);
179
+ return;
180
+ }
181
+ }
182
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * 기본 스펙과 **좌측 패널 프리셋 카탈로그**.
3
+ *
4
+ * 프리셋 룩은 범용 차트 라이브러리 기본값이 아니라 우리 템플릿이 실제로 쓰는 형태에서
5
+ * 뽑았다(가로 막대 + 라벨/값 한 줄 + 강조 항목 하나). 범용 차트 스타일은 상세페이지에
6
+ * 얹는 순간 바로 "AI티"가 난다 — 공용 도형 카탈로그를 템플릿에서 추출한 것과 같은 원칙.
7
+ */
8
+
9
+ import type { ChartCard, ChartKind, ChartSpec, ChartStyle } from "./types";
10
+
11
+ /** 폰트를 못 정했을 때. 편집기 캔버스가 쓰는 본문 폰트와 같다. */
12
+ export const FALLBACK_CHART_FONT = "Pretendard";
13
+
14
+ /**
15
+ * 기본 팔레트 — 무채색 램프.
16
+ *
17
+ * 알록달록한 기본 팔레트를 주면 사용자가 그대로 두고, 상세페이지 전체 톤이 깨진다.
18
+ * 흑연 램프는 어떤 브랜드 색과도 안 싸우고, 강조 색은 사용자가 하나만 고르면 된다.
19
+ */
20
+ export const DEFAULT_CHART_PALETTE = ["#18181B", "#52525B", "#A1A1AA", "#D4D4D8"];
21
+
22
+ export const DEFAULT_CHART_STYLE: ChartStyle = {
23
+ palette: DEFAULT_CHART_PALETTE,
24
+ mutedColor: "#D4D4D8",
25
+ trackColor: "#F4F4F5",
26
+ labelColor: "#3F3F46",
27
+ valueColor: "#18181B",
28
+ fontFamily: FALLBACK_CHART_FONT,
29
+ labelSize: 20,
30
+ valueSize: 22,
31
+ barSize: 16,
32
+ gap: 26,
33
+ labelGap: 12,
34
+ plotSize: 260,
35
+ cornerRadius: 8,
36
+ showTrack: true,
37
+ };
38
+
39
+ /** 새 차트를 놓을 때 들어가는 예시 데이터(사용자가 바로 갈아 끼울 것). */
40
+ const SAMPLE = {
41
+ labels: ["본 제품", "일반 제품 A", "일반 제품 B"],
42
+ values: [92, 61, 45],
43
+ };
44
+
45
+ export type ChartSpecInit = {
46
+ kind?: ChartKind;
47
+ width: number;
48
+ fontFamily?: string;
49
+ style?: Partial<ChartStyle>;
50
+ options?: Partial<ChartSpec["options"]>;
51
+ data?: ChartSpec["data"];
52
+ };
53
+
54
+ /** 기본 스펙 한 벌. ``frame.height``는 렌더가 채우므로 0으로 시작한다. */
55
+ export function createChartSpec({
56
+ kind = "bar-h",
57
+ width,
58
+ fontFamily,
59
+ style,
60
+ options,
61
+ data,
62
+ }: ChartSpecInit): ChartSpec {
63
+ return {
64
+ v: 1,
65
+ kind,
66
+ frame: { width: Math.max(80, Math.round(width)), height: 0 },
67
+ data: data ?? {
68
+ labels: [...SAMPLE.labels],
69
+ series: [{ name: "값", values: [...SAMPLE.values] }],
70
+ },
71
+ options: {
72
+ max: "auto",
73
+ unit: "",
74
+ decimals: 0,
75
+ showValue: true,
76
+ sort: "none",
77
+ highlightIndex: 0,
78
+ ...options,
79
+ },
80
+ style: {
81
+ ...DEFAULT_CHART_STYLE,
82
+ fontFamily: fontFamily || FALLBACK_CHART_FONT,
83
+ ...style,
84
+ },
85
+ };
86
+ }
87
+
88
+ export type ChartPreset = {
89
+ id: string;
90
+ kind: ChartKind;
91
+ /** ``branding`` 네임스페이스의 라벨 키. */
92
+ labelKey: string;
93
+ style?: Partial<ChartStyle>;
94
+ options?: Partial<ChartSpec["options"]>;
95
+ /** 예시 데이터. 안 주면 ``SAMPLE``. 시리즈가 여럿이어야 뜻이 사는 종류만 채운다. */
96
+ data?: ChartSpec["data"];
97
+ };
98
+
99
+ /** 여러 줄 비교용 예시(꺾은선·누적). 한 줄짜리 샘플로는 범례가 안 뜬다. */
100
+ const MULTI_SAMPLE: ChartSpec["data"] = {
101
+ labels: ["1주", "2주", "4주", "8주"],
102
+ series: [
103
+ { name: "본 제품", values: [42, 61, 78, 92] },
104
+ { name: "일반 제품", values: [40, 47, 53, 58] },
105
+ ],
106
+ };
107
+
108
+ /**
109
+ * 프리셋이 기본으로 까는 **흰 바탕**.
110
+ *
111
+ * 차트는 글자와 얇은 선으로 이루어져 있어서 바탕이 없으면 섹션 배경색을 그대로 탄다.
112
+ * 어두운 섹션에 놓는 순간 검은 글씨가 사라지고, 미리보기(흰 패널 위)와 결과가 달라진다.
113
+ * ``card``는 이미 있던 기구다 — 여기서는 테두리 없이 바탕과 안여백만 쓴다.
114
+ *
115
+ * 기본값(``DEFAULT_CHART_STYLE``)이 아니라 **프리셋에만** 건다. 기본값을 바꾸면 이미
116
+ * 문서에 박힌 차트들이 다시 그려질 때 같이 변한다.
117
+ */
118
+ const CARD_PLAIN: ChartCard = {
119
+ fill: "#FFFFFF",
120
+ stroke: null,
121
+ width: 0,
122
+ radius: 8,
123
+ pad: 24,
124
+ };
125
+
126
+ /**
127
+ * 좌측 패널에 뜨는 프리셋.
128
+ *
129
+ * 종류당 하나가 아니라 "쓰임"당 하나다 — 같은 가로 막대라도 비교표(강조 있음)와
130
+ * 순위표(강조 없음, 정렬됨)는 다른 물건이다.
131
+ */
132
+ const PRESETS: ChartPreset[] = [
133
+ {
134
+ id: "bar-h-compare",
135
+ kind: "bar-h",
136
+ labelKey: "detailPage.chart.presets.barHCompare",
137
+ options: { highlightIndex: 0 },
138
+ },
139
+ {
140
+ id: "bar-h-rank",
141
+ kind: "bar-h",
142
+ labelKey: "detailPage.chart.presets.barHRank",
143
+ options: { highlightIndex: null, sort: "desc" },
144
+ style: { showTrack: false, barSize: 22 },
145
+ },
146
+ {
147
+ id: "bar-h-percent",
148
+ kind: "bar-h",
149
+ labelKey: "detailPage.chart.presets.barHPercent",
150
+ options: { unit: "%", max: 100, highlightIndex: null },
151
+ style: { barSize: 12, cornerRadius: 6 },
152
+ },
153
+ {
154
+ id: "bar-v-compare",
155
+ kind: "bar-v",
156
+ labelKey: "detailPage.chart.presets.barVCompare",
157
+ options: { highlightIndex: 0 },
158
+ },
159
+ {
160
+ id: "bar-v-plain",
161
+ kind: "bar-v",
162
+ labelKey: "detailPage.chart.presets.barVPlain",
163
+ options: { highlightIndex: null },
164
+ style: { showTrack: false, barSize: 56, cornerRadius: 6 },
165
+ },
166
+ {
167
+ // 축 있는 막대그래프 — 브랜드 상세페이지가 실제로 쓰는 모양이다(기둥 밴드 ·
168
+ // 점선 눈금선 · 좌·하 축선 · 왼쪽 눈금 라벨 · 카드 프레임). 클라이언트 디자이너
169
+ // 요구사항 "도표의 경우 선을 명확하게 사용"이 여기 들어 있다.
170
+ //
171
+ // 지금 브랜드 템플릿은 이 모양을 손으로 계산한 통짜 SVG로 굽는다("1% = 6.5px"를
172
+ // 네 곳에 반영). 이 프리셋은 같은 그림을 resolved.max 하나에서 뽑으므로 수치를
173
+ // 고치면 막대·눈금선·눈금 라벨이 같이 따라간다.
174
+ id: "bar-v-axis",
175
+ kind: "bar-v",
176
+ labelKey: "detailPage.chart.presets.barVAxis",
177
+ options: { unit: "%", highlightIndex: null },
178
+ style: {
179
+ showTrack: false,
180
+ cornerRadius: 0,
181
+ barSize: 72,
182
+ plot: {
183
+ bands: "#F6F7F9",
184
+ grid: { count: 4, color: "#E3E5E8", dash: true },
185
+ axis: { color: "#1F2124", width: 1.4 },
186
+ ticks: { color: "#8A8F96", size: 14, unitCap: "(%)", gutter: 52 },
187
+ },
188
+ card: { fill: "#FFFFFF", stroke: "#E3E5E8", width: 1, radius: 4, pad: 26 },
189
+ },
190
+ },
191
+ {
192
+ id: "donut-share",
193
+ kind: "donut",
194
+ labelKey: "detailPage.chart.presets.donutShare",
195
+ options: { unit: "%", highlightIndex: 0 },
196
+ style: { showTrack: false },
197
+ },
198
+ {
199
+ id: "gauge-rate",
200
+ kind: "gauge",
201
+ labelKey: "detailPage.chart.presets.gaugeRate",
202
+ options: { unit: "%", max: 100, highlightIndex: 0 },
203
+ },
204
+ {
205
+ id: "line-trend",
206
+ kind: "line",
207
+ labelKey: "detailPage.chart.presets.lineTrend",
208
+ options: { highlightIndex: null },
209
+ style: { showTrack: false },
210
+ },
211
+ {
212
+ id: "stack-mix",
213
+ kind: "stack",
214
+ labelKey: "detailPage.chart.presets.stackMix",
215
+ options: { highlightIndex: null },
216
+ style: { showTrack: false },
217
+ },
218
+ {
219
+ // 값만 남긴 가로 막대 — 트랙도 강조도 없이 순위만 읽히면 되는 자리.
220
+ id: "bar-h-minimal",
221
+ kind: "bar-h",
222
+ labelKey: "detailPage.chart.presets.barHMinimal",
223
+ options: { highlightIndex: null, showValue: true },
224
+ style: { showTrack: false, barSize: 10, cornerRadius: 5, gap: 34 },
225
+ },
226
+ {
227
+ // 순위 세로 막대 — 내림차순으로 세워 1위가 왼쪽에 온다.
228
+ id: "bar-v-rank",
229
+ kind: "bar-v",
230
+ labelKey: "detailPage.chart.presets.barVRank",
231
+ options: { highlightIndex: 0, sort: "desc" },
232
+ style: { showTrack: false, barSize: 48, cornerRadius: 6 },
233
+ },
234
+ {
235
+ // 굵은 도넛 — 비중 하나를 크게 보여 준다(만족도·재구매율).
236
+ id: "donut-single",
237
+ kind: "donut",
238
+ labelKey: "detailPage.chart.presets.donutSingle",
239
+ options: { unit: "%", max: 100, highlightIndex: 0 },
240
+ style: { showTrack: true, plotSize: 300 },
241
+ },
242
+ {
243
+ // 점수 게이지 — 100점 만점 대신 단위를 "점"으로 둔다.
244
+ id: "gauge-score",
245
+ kind: "gauge",
246
+ labelKey: "detailPage.chart.presets.gaugeScore",
247
+ options: { unit: "점", max: 100, decimals: 0, highlightIndex: 0 },
248
+ style: { showTrack: true, barSize: 26 },
249
+ },
250
+ {
251
+ // 여러 줄 꺾은선 — 우리와 경쟁 제품을 같은 축에서 비교한다(범례가 붙는다).
252
+ id: "line-compare",
253
+ kind: "line",
254
+ labelKey: "detailPage.chart.presets.lineCompare",
255
+ options: { highlightIndex: null, showValue: false },
256
+ style: { showTrack: false, plotSize: 280 },
257
+ data: MULTI_SAMPLE,
258
+ },
259
+ {
260
+ // 100% 누적 — 구성비를 한 줄에 채운다(성분 비율·연령대 분포).
261
+ id: "stack-share",
262
+ kind: "stack",
263
+ labelKey: "detailPage.chart.presets.stackShare",
264
+ options: { unit: "%", max: 100, highlightIndex: null },
265
+ style: { showTrack: false, barSize: 34, cornerRadius: 4 },
266
+ data: {
267
+ labels: ["보습", "진정", "탄력"],
268
+ series: [
269
+ { name: "본 제품", values: [48, 32, 20] },
270
+ { name: "일반 제품", values: [30, 30, 40] },
271
+ ],
272
+ },
273
+ },
274
+ ];
275
+
276
+ /**
277
+ * 좌측 패널이 읽는 최종 목록. 바탕을 안 정한 프리셋에는 흰 카드를 깐다 —
278
+ * 삽입 결과가 미리보기와 같아야 한다(미리보기는 흰 패널 위에서 그려진다).
279
+ */
280
+ export const CHART_PRESETS: ChartPreset[] = PRESETS.map((preset) => ({
281
+ ...preset,
282
+ style: { card: CARD_PLAIN, ...preset.style },
283
+ }));
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 차트 값의 **문자열 ↔ 숫자** 변환.
3
+ *
4
+ * 사용자는 값을 엑셀에서 붙여넣거나 손으로 친다. "1,234", "45%", "0.9ms", " 12 "
5
+ * 같은 것들이 그대로 들어오므로, 읽을 때는 관용적으로 흡수하고 쓸 때는 스펙의
6
+ * 단위·소수점 규칙 하나로만 찍는다(그래야 라벨이 제각각이 되지 않는다).
7
+ */
8
+
9
+ /**
10
+ * 사람이 쓴 값 문자열을 숫자로. 못 읽으면 ``null``(= 값 없음, 0이 아니다).
11
+ *
12
+ * 천단위 콤마는 버리고, 단위 접미사·앞뒤 공백·통화기호도 떼어낸다. 소수점 콤마
13
+ * ("1,5")는 **지원하지 않는다** — 천단위 콤마와 구분할 방법이 없고, 한국어 상세페이지
14
+ * 맥락에서는 천단위 쪽이 압도적으로 흔하다.
15
+ */
16
+ export function parseChartNumber(input: unknown): number | null {
17
+ if (typeof input === "number") return Number.isFinite(input) ? input : null;
18
+ if (typeof input !== "string") return null;
19
+ const trimmed = input.trim();
20
+ if (!trimmed) return null;
21
+ // 부호 + 숫자 + (천단위 콤마) + 소수부만 남긴다. 앞뒤에 붙은 단위/기호는 버린다.
22
+ const match = trimmed.match(/-?\d[\d,]*(?:\.\d+)?/);
23
+ if (!match) return null;
24
+ const parsed = Number(match[0].replace(/,/g, ""));
25
+ return Number.isFinite(parsed) ? parsed : null;
26
+ }
27
+
28
+ export type FormatOptions = {
29
+ decimals?: number;
30
+ unit?: string;
31
+ /** 천단위 구분. 기본 켬 — 상세페이지 수치는 대개 크다. */
32
+ grouping?: boolean;
33
+ };
34
+
35
+ /** 값 + 단위. ``null``이면 빈 문자열(칸을 비워 두는 게 0으로 거짓말하는 것보다 낫다). */
36
+ export function formatChartValue(
37
+ value: number | null,
38
+ { decimals = 0, unit = "", grouping = true }: FormatOptions = {},
39
+ ): string {
40
+ if (value === null || !Number.isFinite(value)) return "";
41
+ const places = Math.max(0, Math.min(6, Math.round(decimals)));
42
+ const fixed = value.toFixed(places);
43
+ if (!grouping) return `${fixed}${unit}`;
44
+ const [whole, fraction] = fixed.split(".");
45
+ const sign = whole.startsWith("-") ? "-" : "";
46
+ const digits = sign ? whole.slice(1) : whole;
47
+ const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
48
+ return `${sign}${grouped}${fraction ? `.${fraction}` : ""}${unit}`;
49
+ }
50
+
51
+ /**
52
+ * 데이터에서 소수 자릿수를 추론한다(붙여넣기 직후 기본값 잡기용).
53
+ *
54
+ * 하나라도 소수를 쓰면 그 최대 자릿수를 따라간다. 2자리를 넘기면 차트 라벨이 길어져
55
+ * 읽기 어려우니 2에서 자른다.
56
+ */
57
+ export function inferDecimals(values: ReadonlyArray<number | null>): number {
58
+ let most = 0;
59
+ for (const value of values) {
60
+ if (value === null || !Number.isFinite(value)) continue;
61
+ const text = String(value);
62
+ const dot = text.indexOf(".");
63
+ if (dot >= 0) most = Math.max(most, text.length - dot - 1);
64
+ }
65
+ return Math.min(2, most);
66
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * 스펙 → **렌더에 바로 쓸 수 있는 형태**로 고르는 단계.
3
+ *
4
+ * 여기서 지키는 규칙 하나: **데이터를 절대 잘라내지 않는다.** 종류마다 그릴 수 있는
5
+ * 시리즈 수가 다르지만(도넛은 하나뿐), 못 그리는 시리즈는 렌더에서 빼기만 하고
6
+ * ``spec.data``에는 그대로 남긴다. 그래야 막대 → 도넛 → 막대로 돌아왔을 때 잃었던
7
+ * 시리즈가 되살아난다. Canva가 "차트 종류를 바꿔도 데이터가 유지된다"고 말하는
8
+ * 부분이 정확히 이것이다.
9
+ */
10
+
11
+ import type { ChartKind, ChartSeries, ChartSpec } from "./types";
12
+
13
+ /** 종류별로 **그릴 수 있는** 시리즈 수. ``null``이면 제한 없음. */
14
+ export const SERIES_LIMIT: Record<ChartKind, number | null> = {
15
+ "bar-h": 1,
16
+ "bar-v": 1,
17
+ donut: 1,
18
+ gauge: 1,
19
+ line: null,
20
+ stack: null,
21
+ };
22
+
23
+ export type ResolvedChart = {
24
+ /** 정렬이 적용된 뒤의 라벨. */
25
+ labels: string[];
26
+ /** 렌더에 실제로 쓰이는 시리즈(정렬 적용). */
27
+ series: ChartSeries[];
28
+ /** 축 최댓값(항상 > 0). */
29
+ max: number;
30
+ /**
31
+ * **정렬 후 위치로 옮긴** 강조 인덱스.
32
+ *
33
+ * ``options.highlightIndex``는 사용자가 표에서 고른 원본 행 번호다. 정렬을 켜면
34
+ * 그 행이 다른 자리로 가므로, 옮겨진 자리를 다시 계산해 두지 않으면 엉뚱한 막대가
35
+ * 강조된다.
36
+ */
37
+ highlightIndex: number | null;
38
+ /** 스펙에 남아 있지만 이 종류에서는 안 그려지는 시리즈 수. 0이면 알릴 것 없음. */
39
+ hiddenSeries: number;
40
+ };
41
+
42
+ /** 정렬 기준이 되는 시리즈(첫 번째)의 값. 없으면 전부 null. */
43
+ function sortKeyValues(series: ChartSeries[], length: number): (number | null)[] {
44
+ const first = series[0]?.values ?? [];
45
+ return Array.from({ length }, (_, i) => first[i] ?? null);
46
+ }
47
+
48
+ /**
49
+ * 정렬된 인덱스 순서. ``null``은 항상 뒤로 보낸다 — 빈 칸이 큰 값 사이에 끼면
50
+ * 막대가 끊긴 것처럼 보인다.
51
+ */
52
+ function orderOf(
53
+ values: (number | null)[],
54
+ sort: ChartSpec["options"]["sort"],
55
+ ): number[] {
56
+ const order = values.map((_, i) => i);
57
+ if (sort === "none") return order;
58
+ const dir = sort === "desc" ? -1 : 1;
59
+ return order.sort((a, b) => {
60
+ const va = values[a];
61
+ const vb = values[b];
62
+ if (va === null && vb === null) return a - b;
63
+ if (va === null) return 1;
64
+ if (vb === null) return -1;
65
+ if (va === vb) return a - b; // 동점이면 원래 순서 유지(안정 정렬)
66
+ return (va - vb) * dir;
67
+ });
68
+ }
69
+
70
+ /**
71
+ * 축 최댓값.
72
+ *
73
+ * ``"auto"``면 모든 시리즈의 최댓값. 값이 전부 0/음수/없음이면 1로 떨어진다 — 0으로
74
+ * 나눠 NaN 폭을 만드는 것보다 "전부 빈 막대"가 낫다. 누적(stack)은 항목별 합이 축을
75
+ * 정하므로 합으로 잰다.
76
+ */
77
+ function resolveMax(
78
+ kind: ChartKind,
79
+ series: ChartSeries[],
80
+ labelCount: number,
81
+ max: ChartSpec["options"]["max"],
82
+ ): number {
83
+ if (typeof max === "number" && Number.isFinite(max) && max > 0) return max;
84
+ let found = 0;
85
+ if (kind === "stack") {
86
+ for (let i = 0; i < labelCount; i += 1) {
87
+ let sum = 0;
88
+ for (const s of series) sum += Math.max(0, s.values[i] ?? 0);
89
+ found = Math.max(found, sum);
90
+ }
91
+ } else {
92
+ for (const s of series) {
93
+ for (const value of s.values) {
94
+ if (value !== null && Number.isFinite(value)) found = Math.max(found, value);
95
+ }
96
+ }
97
+ }
98
+ return found > 0 ? found : 1;
99
+ }
100
+
101
+ /** 스펙을 렌더 가능한 형태로 고른다. */
102
+ export function resolveChart(spec: ChartSpec): ResolvedChart {
103
+ const limit = SERIES_LIMIT[spec.kind];
104
+ const all = spec.data.series;
105
+ const visible = limit === null ? all : all.slice(0, limit);
106
+ const labelCount = spec.data.labels.length;
107
+
108
+ const order = orderOf(sortKeyValues(visible, labelCount), spec.options.sort);
109
+ const labels = order.map((i) => spec.data.labels[i] ?? "");
110
+ const series = visible.map((s) => ({
111
+ name: s.name,
112
+ values: order.map((i) => s.values[i] ?? null),
113
+ }));
114
+
115
+ const source = spec.options.highlightIndex;
116
+ const moved =
117
+ source === null || source === undefined ? -1 : order.indexOf(source);
118
+
119
+ return {
120
+ labels,
121
+ series,
122
+ max: resolveMax(spec.kind, series, labelCount, spec.options.max),
123
+ highlightIndex: moved >= 0 ? moved : null,
124
+ hiddenSeries: Math.max(0, all.length - visible.length),
125
+ };
126
+ }
127
+
128
+ /**
129
+ * 항목 색: 강조가 지정돼 있으면 그 항목만 팔레트 첫 색, 나머지는 muted.
130
+ *
131
+ * ``highlight``는 **정렬 후** 인덱스여야 한다(``resolveChart``가 옮겨 준 값).
132
+ */
133
+ export function itemColor(
134
+ spec: ChartSpec,
135
+ index: number,
136
+ highlight: number | null,
137
+ ): string {
138
+ const { palette, mutedColor } = spec.style;
139
+ if (highlight !== null && highlight !== undefined) {
140
+ return index === highlight ? (palette[0] ?? mutedColor) : mutedColor;
141
+ }
142
+ return palette[index % Math.max(1, palette.length)] ?? mutedColor;
143
+ }
144
+
145
+ /** 0~1 비율. 축이 0이거나 값이 없으면 0. */
146
+ export function ratioOf(value: number | null, max: number): number {
147
+ if (value === null || !Number.isFinite(value) || max <= 0) return 0;
148
+ return Math.max(0, Math.min(1, value / max));
149
+ }