@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,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 팔레트를 **차트 종류에 맞는 슬롯**으로 펼친다.
|
|
3
|
+
*
|
|
4
|
+
* ``style.palette``는 한 배열인데 종류마다 무엇에 대응하는지가 다르다:
|
|
5
|
+
*
|
|
6
|
+
* - 막대·도넛·게이지 — **항목별**(``itemColor``가 ``palette[항목 인덱스]``를 쓴다)
|
|
7
|
+
* - 라인·스택 — **시리즈별**(``renderers/line.ts``·``stack.ts``가 ``palette[시리즈 인덱스]``)
|
|
8
|
+
*
|
|
9
|
+
* 그래서 인스펙터도 종류를 보고 슬롯을 펼쳐야 한다. 한 색만 노출하면 "두 번째 막대 색을
|
|
10
|
+
* 못 바꾸는" 상태가 되고, 반대로 항상 여러 개를 펼치면 아래 ``highlightIndex`` 경우에
|
|
11
|
+
* **눌러도 아무 일도 안 일어나는 컨트롤**이 생긴다.
|
|
12
|
+
*
|
|
13
|
+
* ``highlightIndex``가 지정되면 강조 항목만 ``palette[0]``을 쓰고 나머지는 전부
|
|
14
|
+
* ``mutedColor``다(``normalize.ts``의 ``itemColor``). 이때 슬롯은 하나뿐이다.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { DEFAULT_CHART_PALETTE } from "./defaults";
|
|
18
|
+
import type { ChartSpec } from "./types";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 패널에 펼칠 슬롯 상한.
|
|
22
|
+
*
|
|
23
|
+
* 항목이 20개인 차트에 색 고르개를 20개 세우면 인스펙터가 색 목록이 된다. 넘는 항목은
|
|
24
|
+
* 팔레트를 되돌아 쓰므로(``palette[i % length]``) 색이 없어지지는 않는다.
|
|
25
|
+
*/
|
|
26
|
+
export const MAX_PALETTE_SLOTS = 12;
|
|
27
|
+
|
|
28
|
+
export type PaletteScope = "highlight" | "series" | "item";
|
|
29
|
+
|
|
30
|
+
export type PaletteSlot = {
|
|
31
|
+
/** ``style.palette``에서의 자리. */
|
|
32
|
+
index: number;
|
|
33
|
+
/** 데이터가 준 이름(항목 라벨 또는 시리즈 이름). 비어 있을 수 있다. */
|
|
34
|
+
name: string;
|
|
35
|
+
color: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** 이 차트에서 팔레트가 무엇에 대응하는가. */
|
|
39
|
+
export function paletteScope(spec: ChartSpec): PaletteScope {
|
|
40
|
+
if (spec.options.highlightIndex !== null) return "highlight";
|
|
41
|
+
return spec.kind === "line" || spec.kind === "stack" ? "series" : "item";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** ``index``에 실제로 쓰이는 색. 팔레트가 짧으면 되돌아 쓴다(렌더러와 같은 규칙). */
|
|
45
|
+
export function paletteColorAt(spec: ChartSpec, index: number): string {
|
|
46
|
+
const palette = spec.style.palette;
|
|
47
|
+
if (palette.length === 0) return DEFAULT_CHART_PALETTE[0];
|
|
48
|
+
return palette[index % palette.length];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function paletteSlots(spec: ChartSpec): PaletteSlot[] {
|
|
52
|
+
const scope = paletteScope(spec);
|
|
53
|
+
if (scope === "highlight") {
|
|
54
|
+
const at = spec.options.highlightIndex ?? 0;
|
|
55
|
+
return [{ index: 0, name: spec.data.labels[at] ?? "", color: paletteColorAt(spec, 0) }];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const names =
|
|
59
|
+
scope === "series"
|
|
60
|
+
? spec.data.series.map((s) => s.name)
|
|
61
|
+
: spec.data.labels.map((label) => label);
|
|
62
|
+
const count = Math.min(MAX_PALETTE_SLOTS, Math.max(1, names.length));
|
|
63
|
+
return Array.from({ length: count }, (_, index) => ({
|
|
64
|
+
index,
|
|
65
|
+
name: names[index] ?? "",
|
|
66
|
+
color: paletteColorAt(spec, index),
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 슬롯 하나의 색을 바꾼 스펙.
|
|
72
|
+
*
|
|
73
|
+
* 팔레트가 그 자리까지 없으면 **지금 쓰이던 색으로 채워서** 늘린다. 그냥 늘리면 되돌아
|
|
74
|
+
* 쓰기(``palette[i % length]``) 때문에 손대지도 않은 앞쪽 항목들의 색이 같이 바뀐다.
|
|
75
|
+
*/
|
|
76
|
+
export function withPaletteColor(
|
|
77
|
+
spec: ChartSpec,
|
|
78
|
+
index: number,
|
|
79
|
+
color: string,
|
|
80
|
+
): ChartSpec {
|
|
81
|
+
const palette = [...spec.style.palette];
|
|
82
|
+
while (palette.length <= index) palette.push(paletteColorAt(spec, palette.length));
|
|
83
|
+
palette[index] = color;
|
|
84
|
+
return { ...spec, style: { ...spec.style, palette } };
|
|
85
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 엑셀·구글시트에서 **붙여넣기**.
|
|
3
|
+
*
|
|
4
|
+
* 클립보드의 ``text/plain``은 스프레드시트에서 탭 구분 텍스트로 온다. Canva·미리캔버스가
|
|
5
|
+
* 하는 것도 이거고, 파일 업로드보다 압도적으로 자주 쓰인다. CSV(쉼표)·세미콜론도 같이
|
|
6
|
+
* 받아 준다.
|
|
7
|
+
*
|
|
8
|
+
* 첫 행이 머리글인지(값이 하나도 안 읽히는 행인지)로 판단해 시리즈 이름으로 쓴다.
|
|
9
|
+
* 첫 열은 항상 항목 라벨이다.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// 쪼개는 규칙은 표와 한 벌이다(``spec-group/clipboard``).
|
|
13
|
+
import {
|
|
14
|
+
cellsOf,
|
|
15
|
+
pickDelimiter,
|
|
16
|
+
splitLines,
|
|
17
|
+
} from "../spec-group/clipboard";
|
|
18
|
+
|
|
19
|
+
import { parseChartNumber } from "./format";
|
|
20
|
+
import type { ChartData } from "./types";
|
|
21
|
+
|
|
22
|
+
/** 붙여넣기로 받아 줄 최대 크기. 넘으면 잘라낸다(차트로 읽을 수 있는 범위를 넘어선다). */
|
|
23
|
+
export const MAX_PASTE_ROWS = 24;
|
|
24
|
+
export const MAX_PASTE_SERIES = 6;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 붙여넣은 텍스트를 차트 데이터로. 읽을 게 없으면 ``null``.
|
|
28
|
+
*
|
|
29
|
+
* 한 열짜리 숫자 목록도 받는다(라벨은 1,2,3…으로 채운다) — 값만 복사해 오는 경우가
|
|
30
|
+
* 생각보다 흔하다.
|
|
31
|
+
*/
|
|
32
|
+
export function parseChartTable(text: string): ChartData | null {
|
|
33
|
+
const lines = splitLines(text ?? "");
|
|
34
|
+
if (lines.length === 0) return null;
|
|
35
|
+
|
|
36
|
+
const delimiter = pickDelimiter(lines);
|
|
37
|
+
const rows = lines.map((line) => cellsOf(line, delimiter));
|
|
38
|
+
const columns = Math.min(
|
|
39
|
+
MAX_PASTE_SERIES + 1,
|
|
40
|
+
rows.reduce((most, row) => Math.max(most, row.length), 0),
|
|
41
|
+
);
|
|
42
|
+
if (columns === 0) return null;
|
|
43
|
+
|
|
44
|
+
// 한 열짜리: 전부 숫자면 값 목록, 아니면 라벨 목록.
|
|
45
|
+
if (columns === 1) {
|
|
46
|
+
const first = rows.slice(0, MAX_PASTE_ROWS).map((row) => row[0] ?? "");
|
|
47
|
+
const numbers = first.map(parseChartNumber);
|
|
48
|
+
const allNumeric = numbers.every((n) => n !== null);
|
|
49
|
+
if (allNumeric) {
|
|
50
|
+
return {
|
|
51
|
+
labels: first.map((_, i) => String(i + 1)),
|
|
52
|
+
series: [{ name: "값", values: numbers }],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
labels: first,
|
|
57
|
+
series: [{ name: "값", values: first.map(() => null) }],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 머리글 판정: 첫 행의 값 칸(1열 이후)에서 숫자를 하나도 못 읽으면 머리글이다.
|
|
62
|
+
const head = rows[0];
|
|
63
|
+
const headHasNumber = head
|
|
64
|
+
.slice(1, columns)
|
|
65
|
+
.some((cell) => parseChartNumber(cell) !== null);
|
|
66
|
+
const names = headHasNumber
|
|
67
|
+
? Array.from({ length: columns - 1 }, (_, i) => `값 ${i + 1}`)
|
|
68
|
+
: Array.from(
|
|
69
|
+
{ length: columns - 1 },
|
|
70
|
+
(_, i) => head[i + 1] || `값 ${i + 1}`,
|
|
71
|
+
);
|
|
72
|
+
const body = (headHasNumber ? rows : rows.slice(1)).slice(0, MAX_PASTE_ROWS);
|
|
73
|
+
if (body.length === 0) return null;
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
labels: body.map((row) => row[0] ?? ""),
|
|
77
|
+
series: names.map((name, column) => ({
|
|
78
|
+
name,
|
|
79
|
+
values: body.map((row) => parseChartNumber(row[column + 1])),
|
|
80
|
+
})),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 좌측 패널 썸네일용 SVG.
|
|
3
|
+
*
|
|
4
|
+
* 별도 이미지 자산을 만들지 않고 **같은 렌더러 출력**을 SVG로 옮긴다. 그래서 프리셋을
|
|
5
|
+
* 고치면 썸네일이 저절로 따라오고, 카탈로그와 실제 결과가 어긋날 일이 없다.
|
|
6
|
+
*
|
|
7
|
+
* 여기서 만드는 SVG는 화면 미리보기 전용이라 편집기 캔버스에 들어가지 않는다 —
|
|
8
|
+
* ``<img>``로 로드된 SVG는 페이지 폰트를 못 받지만, 썸네일에서는 문제되지 않는다.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { decodeSvgDataUri } from "../../detail-page-canvas/export/svg";
|
|
12
|
+
|
|
13
|
+
import { renderChart } from "./render";
|
|
14
|
+
import type { ChartSpec } from "./types";
|
|
15
|
+
|
|
16
|
+
function escapeXml(text: string): string {
|
|
17
|
+
return text
|
|
18
|
+
.replace(/&/g, "&")
|
|
19
|
+
.replace(/</g, "<")
|
|
20
|
+
.replace(/>/g, ">")
|
|
21
|
+
.replace(/"/g, """);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function num(value: unknown, fallback = 0): number {
|
|
25
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function str(value: unknown, fallback = ""): string {
|
|
29
|
+
return typeof value === "string" ? value : fallback;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 렌더 노드 하나를 SVG 조각으로. */
|
|
33
|
+
function nodeMarkup(props: Record<string, unknown>): string {
|
|
34
|
+
const x = num(props.x);
|
|
35
|
+
const y = num(props.y);
|
|
36
|
+
if (props.type === "svg") {
|
|
37
|
+
// 곡선 기하(도넛 고리·게이지 호·꺾은선)는 이미 SVG다. 루트에 x/y만 얹어 그 자리에
|
|
38
|
+
// 중첩한다(중첩 ``<svg>``는 자기 viewBox를 들고 있어 크기가 그대로 유지된다).
|
|
39
|
+
const inner = decodeSvgDataUri(str(props.src));
|
|
40
|
+
if (!inner) return "";
|
|
41
|
+
return inner.replace(/^<svg\s/, `<svg x="${x}" y="${y}" `);
|
|
42
|
+
}
|
|
43
|
+
if (props.type === "figure") {
|
|
44
|
+
const radius = num(props.cornerRadius);
|
|
45
|
+
// 카드 테두리는 stroke로 그린다 — 안 실으면 썸네일에서 프레임이 사라진다.
|
|
46
|
+
const strokeWidth = num(props.strokeWidth);
|
|
47
|
+
const stroke =
|
|
48
|
+
strokeWidth > 0 && typeof props.stroke === "string"
|
|
49
|
+
? ` stroke="${escapeXml(props.stroke)}" stroke-width="${strokeWidth}"`
|
|
50
|
+
: "";
|
|
51
|
+
return `<rect x="${x}" y="${y}" width="${num(props.width)}" height="${num(
|
|
52
|
+
props.height,
|
|
53
|
+
)}" rx="${radius}" ry="${radius}" fill="${escapeXml(
|
|
54
|
+
str(props.fill, "#000"),
|
|
55
|
+
)}"${stroke}/>`;
|
|
56
|
+
}
|
|
57
|
+
if (props.type === "text") {
|
|
58
|
+
const text = str(props.text);
|
|
59
|
+
if (!text) return "";
|
|
60
|
+
const width = num(props.width);
|
|
61
|
+
const size = num(props.fontSize, 12);
|
|
62
|
+
const align = str(props.align, "left");
|
|
63
|
+
const anchor = align === "right" ? "end" : align === "center" ? "middle" : "start";
|
|
64
|
+
const anchorX = align === "right" ? x + width : align === "center" ? x + width / 2 : x;
|
|
65
|
+
// SVG는 baseline 기준이라 한 줄 높이의 약 0.8을 내려 앉힌다(썸네일 근사치).
|
|
66
|
+
return `<text x="${anchorX}" y="${y + size * 0.85}" font-size="${size}" text-anchor="${anchor}" font-weight="${escapeXml(
|
|
67
|
+
str(props.fontWeight, "400"),
|
|
68
|
+
)}" fill="${escapeXml(str(props.fill, "#000"))}">${escapeXml(text)}</text>`;
|
|
69
|
+
}
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 스펙을 그대로 그린 미리보기 SVG 마크업. */
|
|
74
|
+
export function chartPreviewSvg(spec: ChartSpec): string {
|
|
75
|
+
const { nodes, size } = renderChart(spec);
|
|
76
|
+
const body = nodes.map((node) => nodeMarkup(node.props)).join("");
|
|
77
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size.width} ${size.height}" width="${size.width}" height="${size.height}"><g font-family="sans-serif">${body}</g></svg>`;
|
|
78
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 스펙 → 요소 목록. **순수함수**이고 스토어를 모른다.
|
|
3
|
+
*
|
|
4
|
+
* 종류별 렌더러를 레지스트리로 물고 있어서, 새 종류는 렌더러 파일 하나 + 등록 한 줄로
|
|
5
|
+
* 늘어난다. UI가 고를 수 있는 종류(``CHART_KINDS``)도 이 레지스트리에서 나오므로,
|
|
6
|
+
* 아직 안 만든 종류가 드롭다운에 뜨는 일이 없다.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { resolveChart, type ResolvedChart } from "./normalize";
|
|
10
|
+
import { renderBarH } from "./renderers/bar-h";
|
|
11
|
+
import { renderBarV } from "./renderers/bar-v";
|
|
12
|
+
import { renderDonut } from "./renderers/donut";
|
|
13
|
+
import { renderGauge } from "./renderers/gauge";
|
|
14
|
+
import { renderLine } from "./renderers/line";
|
|
15
|
+
import { renderStack } from "./renderers/stack";
|
|
16
|
+
import type {
|
|
17
|
+
ChartKind,
|
|
18
|
+
ChartNode,
|
|
19
|
+
ChartRender,
|
|
20
|
+
ChartSpec,
|
|
21
|
+
ChartStyle,
|
|
22
|
+
} from "./types";
|
|
23
|
+
|
|
24
|
+
type Renderer = (spec: ChartSpec, resolved: ResolvedChart) => ChartRender;
|
|
25
|
+
|
|
26
|
+
const RENDERERS: Partial<Record<ChartKind, Renderer>> = {
|
|
27
|
+
"bar-h": renderBarH,
|
|
28
|
+
"bar-v": renderBarV,
|
|
29
|
+
donut: renderDonut,
|
|
30
|
+
line: renderLine,
|
|
31
|
+
stack: renderStack,
|
|
32
|
+
gauge: renderGauge,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** UI에 노출할 수 있는 종류(= 렌더러가 있는 종류). */
|
|
36
|
+
export const CHART_KINDS = Object.keys(RENDERERS) as ChartKind[];
|
|
37
|
+
|
|
38
|
+
export function hasRenderer(kind: ChartKind): boolean {
|
|
39
|
+
return Boolean(RENDERERS[kind]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 차트를 카드(바탕 + 테두리 + 안여백)로 감싼다.
|
|
44
|
+
*
|
|
45
|
+
* 내용 전체를 ``pad``만큼 밀고 그만큼 큰 바탕을 맨 뒤에 깐다. 종류를 안 가리므로
|
|
46
|
+
* 렌더러마다 카드 처리를 따로 두지 않는다.
|
|
47
|
+
*
|
|
48
|
+
* 테두리는 채움이 **있는** 사각형의 stroke로 그린다 — 투명 채움(rgba(0,0,0,0))은
|
|
49
|
+
* .ai 내보내기에서 검은 박스로 굳은 전례가 있다.
|
|
50
|
+
*/
|
|
51
|
+
function withCard(render: ChartRender, spec: ChartSpec): ChartRender {
|
|
52
|
+
const card = spec.style.card;
|
|
53
|
+
if (!card) return render;
|
|
54
|
+
const pad = Math.max(0, card.pad);
|
|
55
|
+
const width = render.size.width + pad * 2;
|
|
56
|
+
const height = render.size.height + pad * 2;
|
|
57
|
+
const base: ChartNode = {
|
|
58
|
+
key: "card",
|
|
59
|
+
props: {
|
|
60
|
+
type: "figure",
|
|
61
|
+
subType: "rect",
|
|
62
|
+
x: 0,
|
|
63
|
+
y: 0,
|
|
64
|
+
width: Math.round(width),
|
|
65
|
+
height: Math.round(height),
|
|
66
|
+
fill: card.fill,
|
|
67
|
+
cornerRadius: Math.round(card.radius),
|
|
68
|
+
...(card.stroke && card.width > 0
|
|
69
|
+
? { stroke: card.stroke, strokeWidth: card.width }
|
|
70
|
+
: {}),
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
const moved = render.nodes.map((node) => ({
|
|
74
|
+
...node,
|
|
75
|
+
props: {
|
|
76
|
+
...node.props,
|
|
77
|
+
x: Math.round(Number(node.props.x ?? 0)) + pad,
|
|
78
|
+
y: Math.round(Number(node.props.y ?? 0)) + pad,
|
|
79
|
+
},
|
|
80
|
+
}));
|
|
81
|
+
return {
|
|
82
|
+
nodes: [base, ...moved],
|
|
83
|
+
size: { width: Math.round(width), height: Math.round(height) },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 렌더. 등록 안 된 종류는 가로 막대로 떨어진다 — 예전 문서에 저장된 종류를 열었을 때
|
|
89
|
+
* 빈 그룹이 되는 것보다는 낫다.
|
|
90
|
+
*/
|
|
91
|
+
export function renderChart(spec: ChartSpec): ChartRender {
|
|
92
|
+
const render = RENDERERS[spec.kind] ?? renderBarH;
|
|
93
|
+
// 카드 여백은 **안쪽으로** 판다. 내용을 그만큼 좁게 그리고 카드가 원래 폭을 되찾는다.
|
|
94
|
+
//
|
|
95
|
+
// 밖으로 밀면 렌더 폭이 `frame.width`보다 커지는데, 그 값이 그대로 `frame.width`로
|
|
96
|
+
// 저장된다(`spec-group/sync.ts`). 데이터를 고칠 때마다 차트가 여백 두 배씩 넓어진다.
|
|
97
|
+
const pad = Math.max(0, spec.style.card?.pad ?? 0);
|
|
98
|
+
const inner =
|
|
99
|
+
pad > 0
|
|
100
|
+
? {
|
|
101
|
+
...spec,
|
|
102
|
+
frame: { ...spec.frame, width: Math.max(80, spec.frame.width - pad * 2) },
|
|
103
|
+
}
|
|
104
|
+
: spec;
|
|
105
|
+
return withCard(render(inner, resolveChart(inner)), spec);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 세로 리듬(글자·막대·간격)을 함께 키우고 줄이는 스타일 키. */
|
|
109
|
+
const SCALABLE: (keyof ChartStyle)[] = [
|
|
110
|
+
"labelSize",
|
|
111
|
+
"valueSize",
|
|
112
|
+
"barSize",
|
|
113
|
+
"gap",
|
|
114
|
+
"labelGap",
|
|
115
|
+
"plotSize",
|
|
116
|
+
"cornerRadius",
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 세로 리듬만 배율로 키우거나 줄인다(프레임 폭은 호출자가 정한다).
|
|
121
|
+
*
|
|
122
|
+
* 리사이즈 흡수와 패널 썸네일이 같은 함수를 쓴다 — 그래야 썸네일이 실제 결과와 같은
|
|
123
|
+
* 비례로 보인다.
|
|
124
|
+
*/
|
|
125
|
+
export function scaleChartStyle(spec: ChartSpec, factor: number): ChartSpec {
|
|
126
|
+
const scale = Math.max(0.05, Math.min(20, factor));
|
|
127
|
+
const style: ChartStyle = { ...spec.style };
|
|
128
|
+
const writable = style as unknown as Record<string, unknown>;
|
|
129
|
+
for (const key of SCALABLE) {
|
|
130
|
+
const value = style[key];
|
|
131
|
+
if (typeof value === "number") {
|
|
132
|
+
writable[key] = Math.max(1, Math.round(value * scale));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// 판 크롬·카드도 같이 줄인다. 안 그러면 썸네일에서 눈금 글자와 카드 여백만 원래
|
|
136
|
+
// 크기로 남아 실제 결과와 다른 비례로 보인다. 선 두께는 안 건드린다 — 0.3px 선은
|
|
137
|
+
// 썸네일에서 사라져 "선이 없는 차트"로 보인다.
|
|
138
|
+
if (style.plot?.ticks) {
|
|
139
|
+
style.plot = {
|
|
140
|
+
...style.plot,
|
|
141
|
+
ticks: {
|
|
142
|
+
...style.plot.ticks,
|
|
143
|
+
size: Math.max(4, Math.round(style.plot.ticks.size * scale)),
|
|
144
|
+
gutter: Math.max(8, Math.round(style.plot.ticks.gutter * scale)),
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (style.card) {
|
|
149
|
+
style.card = {
|
|
150
|
+
...style.card,
|
|
151
|
+
pad: Math.max(2, Math.round(style.card.pad * scale)),
|
|
152
|
+
radius: Math.max(0, Math.round(style.card.radius * scale)),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return { ...spec, style };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 사용자가 그룹을 잡아 늘린 걸 스펙에 **흡수**한다.
|
|
160
|
+
*
|
|
161
|
+
* 스톡 편집기는 그룹을 늘리면 자식을 스케일한다. 그 상태에서 데이터만 고쳐 다시 그리면
|
|
162
|
+
* 원래 크기로 튕겨 돌아가 버린다. 그래서 재생성 직전에 실제 상자를 재서:
|
|
163
|
+
*
|
|
164
|
+
* - 가로 변화 → ``frame.width``만 갱신(막대가 길어질 뿐, 글자는 그대로)
|
|
165
|
+
* - 세로 변화 → 세로 리듬(글자·막대 두께·간격)을 그 비율로 스케일
|
|
166
|
+
*
|
|
167
|
+
* 가로와 세로를 다르게 다루는 게 의도다. 가로로 늘렸을 때 글자까지 커지면 "폭만 넓히고
|
|
168
|
+
* 싶었는데" 라는 상황이 매번 생긴다.
|
|
169
|
+
*/
|
|
170
|
+
export function absorbResize(
|
|
171
|
+
spec: ChartSpec,
|
|
172
|
+
actual: { width: number; height: number },
|
|
173
|
+
): ChartSpec {
|
|
174
|
+
const width = Math.max(40, Math.round(actual.width));
|
|
175
|
+
const previous = spec.frame.height;
|
|
176
|
+
const changedHeight =
|
|
177
|
+
previous > 0 && Math.abs(actual.height - previous) > 1;
|
|
178
|
+
if (!changedHeight) {
|
|
179
|
+
return width === spec.frame.width
|
|
180
|
+
? spec
|
|
181
|
+
: { ...spec, frame: { ...spec.frame, width } };
|
|
182
|
+
}
|
|
183
|
+
const scaled = scaleChartStyle(
|
|
184
|
+
spec,
|
|
185
|
+
Math.max(0.2, Math.min(5, actual.height / previous)),
|
|
186
|
+
);
|
|
187
|
+
return { ...scaled, frame: { width, height: spec.frame.height } };
|
|
188
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 가로 막대 — 상세페이지에서 가장 많이 쓰는 형태.
|
|
3
|
+
*
|
|
4
|
+
* 한 행 = 라벨(왼쪽) · 값(오른쪽) 한 줄 + 그 아래 꽉 찬 막대. 템플릿의 벤치마크 섹션
|
|
5
|
+
* (``F-digital-compat/.../benchmark``)이 손으로 그리던 구조 그대로이고, 다른 점은
|
|
6
|
+
* **막대 길이가 값에서 나온다**는 것 하나다.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { formatChartValue } from "../format";
|
|
10
|
+
import { itemColor, ratioOf, type ResolvedChart } from "../normalize";
|
|
11
|
+
import type { ChartNode, ChartRender, ChartSpec } from "../types";
|
|
12
|
+
import { barRadius, rectNode, textHeight, textNode, visibleLength } from "./shared";
|
|
13
|
+
|
|
14
|
+
/** 라벨이 차지하는 가로 비율. 나머지가 값 칸이다. */
|
|
15
|
+
const LABEL_RATIO = 0.62;
|
|
16
|
+
|
|
17
|
+
export function renderBarH(spec: ChartSpec, resolved: ResolvedChart): ChartRender {
|
|
18
|
+
const { style, options } = spec;
|
|
19
|
+
const width = spec.frame.width;
|
|
20
|
+
const values = resolved.series[0]?.values ?? [];
|
|
21
|
+
const nodes: ChartNode[] = [];
|
|
22
|
+
|
|
23
|
+
const labelH = textHeight(style.labelSize);
|
|
24
|
+
const valueH = textHeight(style.valueSize);
|
|
25
|
+
const headH = Math.max(labelH, valueH);
|
|
26
|
+
const rowH = headH + style.labelGap + style.barSize;
|
|
27
|
+
const radius = barRadius(style, style.barSize);
|
|
28
|
+
const labelWidth = options.showValue ? width * LABEL_RATIO : width;
|
|
29
|
+
|
|
30
|
+
resolved.labels.forEach((label, i) => {
|
|
31
|
+
const top = i * (rowH + style.gap);
|
|
32
|
+
const value = values[i] ?? null;
|
|
33
|
+
const ratio = ratioOf(value, resolved.max);
|
|
34
|
+
const barTop = top + headH + style.labelGap;
|
|
35
|
+
const color = itemColor(spec, i, resolved.highlightIndex);
|
|
36
|
+
|
|
37
|
+
if (style.showTrack) {
|
|
38
|
+
nodes.push(
|
|
39
|
+
rectNode(
|
|
40
|
+
`track:${i}`,
|
|
41
|
+
{ x: 0, y: barTop, width, height: style.barSize },
|
|
42
|
+
style.trackColor,
|
|
43
|
+
radius,
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
nodes.push(
|
|
48
|
+
rectNode(
|
|
49
|
+
`bar:${i}`,
|
|
50
|
+
{
|
|
51
|
+
x: 0,
|
|
52
|
+
y: barTop,
|
|
53
|
+
width: visibleLength(width * ratio, ratio),
|
|
54
|
+
height: style.barSize,
|
|
55
|
+
},
|
|
56
|
+
color,
|
|
57
|
+
radius,
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
nodes.push(
|
|
61
|
+
textNode(
|
|
62
|
+
`label:${i}`,
|
|
63
|
+
{ x: 0, y: top + (headH - labelH) / 2, width: labelWidth },
|
|
64
|
+
label,
|
|
65
|
+
{
|
|
66
|
+
style,
|
|
67
|
+
fontSize: style.labelSize,
|
|
68
|
+
fill: style.labelColor,
|
|
69
|
+
align: "left",
|
|
70
|
+
fontWeight: "500",
|
|
71
|
+
},
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
if (options.showValue) {
|
|
75
|
+
nodes.push(
|
|
76
|
+
textNode(
|
|
77
|
+
`value:${i}`,
|
|
78
|
+
{
|
|
79
|
+
x: labelWidth,
|
|
80
|
+
y: top + (headH - valueH) / 2,
|
|
81
|
+
width: width - labelWidth,
|
|
82
|
+
},
|
|
83
|
+
formatChartValue(value, {
|
|
84
|
+
decimals: options.decimals,
|
|
85
|
+
unit: options.unit,
|
|
86
|
+
}),
|
|
87
|
+
{
|
|
88
|
+
style,
|
|
89
|
+
fontSize: style.valueSize,
|
|
90
|
+
fill: style.valueColor,
|
|
91
|
+
align: "right",
|
|
92
|
+
fontWeight: "700",
|
|
93
|
+
},
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const rows = resolved.labels.length;
|
|
100
|
+
const height =
|
|
101
|
+
rows > 0 ? rows * rowH + (rows - 1) * style.gap : Math.round(rowH);
|
|
102
|
+
return { nodes, size: { width, height: Math.round(height) } };
|
|
103
|
+
}
|