@funnest/charts-ui 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,9 +1,5 @@
1
1
  // src/trading-chart.tsx
2
- import {
3
- createChart,
4
- darkTheme,
5
- lightTheme
6
- } from "@funnest/charts";
2
+ import "@funnest/charts";
7
3
 
8
4
  // ../../node_modules/.pnpm/preact@11.2.0/node_modules/preact/dist/preact.mjs
9
5
  var n;
@@ -313,6 +309,7 @@ var STRINGS = {
313
309
  settingHighLow: "\uCD5C\uACE0\xB7\uCD5C\uC800 \uD45C\uC2DC",
314
310
  settingLastPrice: "\uD604\uC7AC\uAC00 \uC120",
315
311
  settingVolume: "\uAC70\uB798\uB7C9",
312
+ settingNavButtons: "\uD655\uB300\xB7\uC774\uB3D9 \uBC84\uD2BC",
316
313
  settingGridH: "\uAC00\uB85C \uACA9\uC790",
317
314
  settingGridV: "\uC138\uB85C \uACA9\uC790",
318
315
  resetDefaults: "\uAE30\uBCF8\uAC12\uC73C\uB85C",
@@ -367,7 +364,17 @@ var STRINGS = {
367
364
  symbols: "\uC885\uBAA9",
368
365
  searching: "\uAC80\uC0C9 \uC911",
369
366
  searchHint: "\u2191\u2193 \uC774\uB3D9 \xB7 Enter \uC120\uD0DD \xB7 Esc \uB2EB\uAE30",
370
- current: "\uD604\uC7AC"
367
+ current: "\uD604\uC7AC",
368
+ layout: "\uD654\uBA74 \uBD84\uD560",
369
+ sync: "\uCE78 \uB3D9\uAE30\uD654",
370
+ syncCrosshair: "\uC2ED\uC790\uC120",
371
+ syncTime: "\uC2DC\uAC04 \uBC94\uC704",
372
+ syncSymbol: "\uC885\uBAA9",
373
+ syncInterval: "\uC8FC\uAE30",
374
+ syncHint: "\uCF1C\uBA74 \uBAA8\uB4E0 \uCE78\uC774 \uAC19\uC774 \uC6C0\uC9C1\uC5EC\uC694",
375
+ maximizePane: "\uC774 \uCE78 \uD06C\uAC8C \uBCF4\uAE30",
376
+ restorePane: "\uBD84\uD560 \uD654\uBA74\uC73C\uB85C",
377
+ pane: "\uCE78"
371
378
  },
372
379
  en: {
373
380
  searchSymbol: "Symbol search",
@@ -409,6 +416,7 @@ var STRINGS = {
409
416
  settingHighLow: "High/low markers",
410
417
  settingLastPrice: "Last price line",
411
418
  settingVolume: "Volume",
419
+ settingNavButtons: "Zoom & scroll buttons",
412
420
  settingGridH: "Horizontal grid",
413
421
  settingGridV: "Vertical grid",
414
422
  resetDefaults: "Reset",
@@ -461,7 +469,17 @@ var STRINGS = {
461
469
  symbols: "Symbols",
462
470
  searching: "Searching",
463
471
  searchHint: "\u2191\u2193 navigate \xB7 Enter select \xB7 Esc close",
464
- current: "Current"
472
+ current: "Current",
473
+ layout: "Layout",
474
+ sync: "Sync panes",
475
+ syncCrosshair: "Crosshair",
476
+ syncTime: "Time range",
477
+ syncSymbol: "Symbol",
478
+ syncInterval: "Interval",
479
+ syncHint: "Linked panes move together",
480
+ maximizePane: "Maximize pane",
481
+ restorePane: "Back to split view",
482
+ pane: "Pane"
465
483
  }
466
484
  };
467
485
  function resolutionLabel(resolution, locale, form = "short") {
@@ -595,6 +613,12 @@ var FunnestMark = () => /* @__PURE__ */ a2("svg", { width: "20", height: "20", v
595
613
  /* @__PURE__ */ a2("path", { d: "M7 13.5C9.5 21.5 17 23.5 23.4 11.2", stroke: "#fff", "stroke-width": "3.2", "stroke-linecap": "round" }),
596
614
  /* @__PURE__ */ a2("circle", { cx: "24.6", cy: "8.6", r: "2.9", fill: "#FFD43B" })
597
615
  ] });
616
+ var LinkIcon = () => /* @__PURE__ */ a2(Icon, { children: [
617
+ /* @__PURE__ */ a2("path", { d: "M8.5 11.5a3 3 0 0 0 4.2 0l2.6-2.6a3 3 0 0 0-4.2-4.2l-1 1" }),
618
+ /* @__PURE__ */ a2("path", { d: "M11.5 8.5a3 3 0 0 0-4.2 0l-2.6 2.6a3 3 0 0 0 4.2 4.2l1-1" })
619
+ ] });
620
+ var MaximizeIcon = () => /* @__PURE__ */ a2(Icon, { size: 16, children: /* @__PURE__ */ a2("path", { d: "M11 4h5v5M9 16H4v-5M16 4l-5 5M4 16l5-5" }) });
621
+ var RestoreIcon = () => /* @__PURE__ */ a2(Icon, { size: 16, children: /* @__PURE__ */ a2("path", { d: "M15 9h-4V5M5 11h4v4M11 9l5-5M9 11l-5 5" }) });
598
622
 
599
623
  // ../../node_modules/.pnpm/preact@11.2.0/node_modules/preact/hooks/dist/hooks.mjs
600
624
  var t2;
@@ -885,251 +909,6 @@ function SelectionBar({ ctx }) {
885
909
  ] });
886
910
  }
887
911
 
888
- // src/components/overlay.tsx
889
- import { formatCompact, formatPrice } from "@funnest/charts";
890
-
891
- // src/components/symbol-avatar.tsx
892
- function hue(text) {
893
- let h3 = 0;
894
- for (const ch of text) h3 = (h3 * 31 + ch.charCodeAt(0)) % 360;
895
- return h3;
896
- }
897
- function SymbolAvatar({ info, size = 24 }) {
898
- const [failed, setFailed] = d2(false);
899
- A2(() => setFailed(false), [info.logoUrl]);
900
- const style = { width: `${size}px`, height: `${size}px`, fontSize: `${Math.round(size * 0.46)}px` };
901
- if (info.logoUrl && !failed) {
902
- return /* @__PURE__ */ a2("img", { class: "fnc-avatar", src: info.logoUrl, alt: "", style, onError: () => setFailed(true) });
903
- }
904
- const h3 = hue(info.symbol);
905
- return /* @__PURE__ */ a2(
906
- "span",
907
- {
908
- class: "fnc-avatar fnc-avatar-initial",
909
- "aria-hidden": "true",
910
- style: { ...style, background: `hsl(${h3} 70% 50% / 0.16)`, color: `hsl(${h3} 60% 42%)` },
911
- children: (info.name || info.symbol).trim().charAt(0).toUpperCase()
912
- }
913
- );
914
- }
915
-
916
- // src/components/overlay.tsx
917
- var ZOOM_STEP = 1.25;
918
- var CONTROL_OFFSET = 52;
919
- var dirClass = (v3) => v3 > 0 ? "fnc-up" : v3 < 0 ? "fnc-down" : "fnc-flat";
920
- function IndicatorItem({ ctx, item }) {
921
- const t3 = STRINGS[ctx.store.get().locale];
922
- return /* @__PURE__ */ a2(
923
- "div",
924
- {
925
- class: item.visible ? "fnc-ind" : "fnc-ind fnc-ind-hidden",
926
- role: "button",
927
- tabIndex: 0,
928
- "aria-label": `${item.title} ${t3.indicatorSettings}`,
929
- onClick: (e3) => {
930
- if (e3.target.closest(".fnc-ind-actions")) return;
931
- ctx.store.set({ dialog: { kind: "indicatorSettings", id: item.id } });
932
- },
933
- onKeyDown: (e3) => e3.key === "Enter" && ctx.store.set({ dialog: { kind: "indicatorSettings", id: item.id } }),
934
- children: [
935
- /* @__PURE__ */ a2(
936
- "button",
937
- {
938
- class: "fnc-ind-btn fnc-ind-eye",
939
- title: item.visible ? t3.hide : t3.show,
940
- "aria-label": `${item.title} ${item.visible ? t3.hide : t3.show}`,
941
- "aria-pressed": !item.visible,
942
- onClick: (e3) => {
943
- e3.stopPropagation();
944
- ctx.chart.setIndicatorVisible(item.id, !item.visible);
945
- },
946
- children: item.visible ? /* @__PURE__ */ a2(EyeIcon, {}) : /* @__PURE__ */ a2(EyeOffIcon, {})
947
- }
948
- ),
949
- /* @__PURE__ */ a2("span", { class: "fnc-ind-title", children: item.title }),
950
- item.visible && item.values.map((v3) => /* @__PURE__ */ a2("b", { style: { color: v3.color }, title: v3.label, children: v3.value })),
951
- /* @__PURE__ */ a2("span", { class: "fnc-ind-actions", children: [
952
- /* @__PURE__ */ a2(
953
- "button",
954
- {
955
- class: "fnc-ind-btn",
956
- title: t3.indicatorSettings,
957
- "aria-label": `${item.title} ${t3.indicatorSettings}`,
958
- onClick: () => ctx.store.set({ dialog: { kind: "indicatorSettings", id: item.id } }),
959
- children: /* @__PURE__ */ a2(GearIcon, {})
960
- }
961
- ),
962
- /* @__PURE__ */ a2(
963
- "button",
964
- {
965
- class: "fnc-ind-btn",
966
- title: t3.removeIndicator,
967
- "aria-label": `${item.title} ${t3.removeIndicator}`,
968
- onClick: () => ctx.chart.removeIndicator(item.id),
969
- children: /* @__PURE__ */ a2(SmallCloseIcon, {})
970
- }
971
- )
972
- ] })
973
- ]
974
- }
975
- );
976
- }
977
- function Legend({ ctx }) {
978
- const s3 = useStore(ctx.store);
979
- const t3 = STRINGS[s3.locale];
980
- const [hover, setHover] = d2(null);
981
- const [, refresh] = _2((n2) => n2 + 1, 0);
982
- const [collapsed, setCollapsed] = d2(false);
983
- A2(() => {
984
- const offs = [
985
- ctx.chart.on(
986
- "crosshairMove",
987
- (info) => setHover(info ? { bar: info.bar, prev: ctx.chart.bars[info.index - 1], index: info.index } : null)
988
- ),
989
- ctx.chart.on("dataChange", () => refresh(void 0))
990
- ];
991
- return () => offs.forEach((off) => off());
992
- }, [ctx.chart]);
993
- const ref = T2(null);
994
- A2(() => {
995
- const el = ref.current;
996
- if (!el) return;
997
- const update = () => ctx.chart.setTopInset(el.offsetTop + el.offsetHeight + 4);
998
- const ro = new ResizeObserver(update);
999
- ro.observe(el);
1000
- return () => ro.disconnect();
1001
- }, [ctx.chart]);
1002
- if (!s3.symbol) return /* @__PURE__ */ a2("div", { class: "fnc-legend", ref });
1003
- const bars = ctx.chart.bars;
1004
- const bar = hover?.bar ?? bars[bars.length - 1];
1005
- const prev = hover ? hover.prev : bars[bars.length - 2];
1006
- const fmt = (p3) => formatPrice(p3, s3.symbol.pricePrecision);
1007
- let values = null;
1008
- if (bar) {
1009
- const cls = dirClass(bar.close - bar.open);
1010
- const change = prev ? bar.close - prev.close : 0;
1011
- const pct = prev && prev.close ? change / prev.close * 100 : 0;
1012
- const sign = change > 0 ? "+" : "";
1013
- values = /* @__PURE__ */ a2("div", { class: "fnc-legend-values", children: [
1014
- /* @__PURE__ */ a2("span", { children: [
1015
- t3.open,
1016
- " ",
1017
- /* @__PURE__ */ a2("b", { class: cls, children: fmt(bar.open) })
1018
- ] }),
1019
- /* @__PURE__ */ a2("span", { children: [
1020
- t3.high,
1021
- " ",
1022
- /* @__PURE__ */ a2("b", { class: cls, children: fmt(bar.high) })
1023
- ] }),
1024
- /* @__PURE__ */ a2("span", { children: [
1025
- t3.low,
1026
- " ",
1027
- /* @__PURE__ */ a2("b", { class: cls, children: fmt(bar.low) })
1028
- ] }),
1029
- /* @__PURE__ */ a2("span", { children: [
1030
- t3.close_,
1031
- " ",
1032
- /* @__PURE__ */ a2("b", { class: cls, children: fmt(bar.close) })
1033
- ] }),
1034
- /* @__PURE__ */ a2("b", { class: dirClass(change), children: [
1035
- sign,
1036
- fmt(change),
1037
- " (",
1038
- sign,
1039
- pct.toFixed(2),
1040
- "%)"
1041
- ] }),
1042
- bar.volume !== void 0 && /* @__PURE__ */ a2("span", { children: [
1043
- t3.volume,
1044
- " ",
1045
- /* @__PURE__ */ a2("b", { class: "fnc-flat", children: formatCompact(bar.volume, s3.locale) })
1046
- ] })
1047
- ] });
1048
- }
1049
- const items = s3.indicators.length ? ctx.chart.indicatorLegend(hover?.index) : [];
1050
- const overlayItems = items.filter((i3) => i3.overlay);
1051
- const paneLegends = s3.layout.panes.filter((p3) => p3.kind === "indicator").map((pane) => ({ pane, item: items.find((i3) => i3.id === pane.id) }));
1052
- return /* @__PURE__ */ a2($, { children: [
1053
- /* @__PURE__ */ a2("div", { class: "fnc-legend", ref, children: [
1054
- /* @__PURE__ */ a2("div", { class: "fnc-legend-title", children: [
1055
- ctx.showLogo && /* @__PURE__ */ a2(SymbolAvatar, { info: s3.symbol, size: 18 }),
1056
- /* @__PURE__ */ a2("b", { children: s3.symbol.name }),
1057
- /* @__PURE__ */ a2("span", { children: "\xB7" }),
1058
- /* @__PURE__ */ a2("span", { children: resolutionLabel(s3.resolution, s3.locale) }),
1059
- /* @__PURE__ */ a2("span", { children: "\xB7" }),
1060
- /* @__PURE__ */ a2("span", { children: s3.symbol.exchange }),
1061
- s3.loading && /* @__PURE__ */ a2("span", { class: "fnc-spinner", role: "status" })
1062
- ] }),
1063
- values,
1064
- !collapsed && overlayItems.map((item) => /* @__PURE__ */ a2(IndicatorItem, { ctx, item })),
1065
- overlayItems.length > 0 && (collapsed ? /* @__PURE__ */ a2("button", { class: "fnc-ind-handle", "aria-expanded": "false", "aria-label": t3.expand, title: t3.expand, onClick: () => setCollapsed(false), children: /* @__PURE__ */ a2("span", {}) }) : /* @__PURE__ */ a2("button", { class: "fnc-ind-collapse", "aria-expanded": "true", "aria-label": t3.collapse, title: t3.collapse, onClick: () => setCollapsed(true), children: /* @__PURE__ */ a2(CollapseLeftIcon, {}) }))
1066
- ] }),
1067
- paneLegends.map(
1068
- ({ pane, item }) => item && /* @__PURE__ */ a2("div", { class: "fnc-legend fnc-pane-legend", style: { top: `${pane.top + 4}px` }, children: /* @__PURE__ */ a2(IndicatorItem, { ctx, item }) })
1069
- )
1070
- ] });
1071
- }
1072
- function NavControls({ ctx }) {
1073
- const s3 = useStore(ctx.store);
1074
- const t3 = STRINGS[s3.locale];
1075
- const { plotWidth, panesHeight } = s3.layout;
1076
- if (plotWidth <= 0) return null;
1077
- const top = panesHeight - CONTROL_OFFSET;
1078
- const page = () => Math.max(1, Math.round(plotWidth / ctx.chart.timeScale.barSpacing / 3));
1079
- const button = (label, onClick, icon) => /* @__PURE__ */ a2("button", { class: "fnc-btn", title: label, "aria-label": label, onClick, children: icon });
1080
- return /* @__PURE__ */ a2($, { children: [
1081
- /* @__PURE__ */ a2("div", { class: "fnc-nav", style: { left: `${plotWidth / 2}px`, top: `${top}px` }, children: [
1082
- button(t3.zoomOut, () => ctx.chart.zoom(1 / ZOOM_STEP), /* @__PURE__ */ a2(MinusIcon, {})),
1083
- button(t3.zoomIn, () => ctx.chart.zoom(ZOOM_STEP), /* @__PURE__ */ a2(PlusIcon, {})),
1084
- button(t3.scrollBack, () => ctx.chart.scrollBars(-page()), /* @__PURE__ */ a2(ChevronLeftIcon, {})),
1085
- button(t3.scrollForward, () => ctx.chart.scrollBars(page()), /* @__PURE__ */ a2(ChevronRightIcon, {})),
1086
- button(t3.resetView, () => ctx.chart.resetView(), /* @__PURE__ */ a2(ResetIcon, {}))
1087
- ] }),
1088
- !s3.atRealtime && /* @__PURE__ */ a2(
1089
- "button",
1090
- {
1091
- class: "fnc-realtime",
1092
- style: { left: `${plotWidth - 44}px`, top: `${top}px` },
1093
- title: t3.toRealtime,
1094
- "aria-label": t3.toRealtime,
1095
- onClick: () => ctx.chart.scrollToRealtime(),
1096
- children: /* @__PURE__ */ a2(ToRealtimeIcon, {})
1097
- }
1098
- )
1099
- ] });
1100
- }
1101
- var ATTRIBUTION_URL = "https://charts.funnest.xyz/?utm_source=chart&utm_medium=attribution";
1102
- function Attribution({ ctx }) {
1103
- const s3 = useStore(ctx.store);
1104
- const { panesHeight, plotWidth } = s3.layout;
1105
- if (!ctx.attribution || plotWidth <= 0) return null;
1106
- const label = s3.locale === "ko" ? "Funnest \uCC28\uD2B8" : "Charts by Funnest";
1107
- return /* @__PURE__ */ a2(
1108
- "a",
1109
- {
1110
- class: "fnc-attribution",
1111
- href: ATTRIBUTION_URL,
1112
- target: "_blank",
1113
- rel: "noopener",
1114
- title: label,
1115
- "aria-label": label,
1116
- style: { top: `${panesHeight - 36}px` },
1117
- children: [
1118
- /* @__PURE__ */ a2(FunnestMark, {}),
1119
- /* @__PURE__ */ a2("span", { children: "funnest" })
1120
- ]
1121
- }
1122
- );
1123
- }
1124
- function Overlay({ ctx }) {
1125
- return /* @__PURE__ */ a2($, { children: [
1126
- /* @__PURE__ */ a2(Attribution, { ctx }),
1127
- /* @__PURE__ */ a2(Legend, { ctx }),
1128
- /* @__PURE__ */ a2(NavControls, { ctx }),
1129
- /* @__PURE__ */ a2(SelectionBar, { ctx })
1130
- ] });
1131
- }
1132
-
1133
912
  // src/components/chart-type-menu.tsx
1134
913
  var TYPES = [
1135
914
  { type: "candles", label: "candles", icon: CandlesIcon },
@@ -1213,7 +992,8 @@ var SECTIONS = [
1213
992
  toggles: [
1214
993
  { key: "highLowMarkers", label: "settingHighLow" },
1215
994
  { key: "lastPriceLine", label: "settingLastPrice" },
1216
- { key: "volume", label: "settingVolume" }
995
+ { key: "volume", label: "settingVolume" },
996
+ { key: "navigationButtons", label: "settingNavButtons" }
1217
997
  ]
1218
998
  },
1219
999
  {
@@ -1330,7 +1110,7 @@ function defaultSeries(type, params) {
1330
1110
  }
1331
1111
  function StyleControls({
1332
1112
  t: t3,
1333
- row,
1113
+ row: row2,
1334
1114
  onChange
1335
1115
  }) {
1336
1116
  return /* @__PURE__ */ a2($, { children: [
@@ -1339,8 +1119,8 @@ function StyleControls({
1339
1119
  {
1340
1120
  type: "checkbox",
1341
1121
  class: "fnc-check",
1342
- checked: row.visible,
1343
- "aria-label": row.visible ? t3.hide : t3.show,
1122
+ checked: row2.visible,
1123
+ "aria-label": row2.visible ? t3.hide : t3.show,
1344
1124
  onChange: (e3) => onChange({ visible: e3.currentTarget.checked })
1345
1125
  }
1346
1126
  ),
@@ -1349,12 +1129,12 @@ function StyleControls({
1349
1129
  {
1350
1130
  type: "color",
1351
1131
  class: "fnc-color",
1352
- value: row.color,
1132
+ value: row2.color,
1353
1133
  "aria-label": t3.color,
1354
1134
  onInput: (e3) => onChange({ color: e3.currentTarget.value })
1355
1135
  }
1356
1136
  ),
1357
- /* @__PURE__ */ a2("select", { class: "fnc-select fnc-select-width", value: row.width, "aria-label": t3.lineWidth, onChange: (e3) => onChange({ width: Number(e3.currentTarget.value) }), children: WIDTHS.map((w3) => /* @__PURE__ */ a2("option", { value: w3, children: [
1137
+ /* @__PURE__ */ a2("select", { class: "fnc-select fnc-select-width", value: row2.width, "aria-label": t3.lineWidth, onChange: (e3) => onChange({ width: Number(e3.currentTarget.value) }), children: WIDTHS.map((w3) => /* @__PURE__ */ a2("option", { value: w3, children: [
1358
1138
  w3,
1359
1139
  "px"
1360
1140
  ] })) })
@@ -1480,9 +1260,9 @@ function IndicatorSettings({ ctx, id }) {
1480
1260
  )
1481
1261
  ] })),
1482
1262
  styles.length > 0 && /* @__PURE__ */ a2("div", { class: "fnc-section", children: t3.style }),
1483
- styles.map((row, i3) => /* @__PURE__ */ a2("div", { class: "fnc-row", children: [
1484
- /* @__PURE__ */ a2("span", { class: "fnc-row-label fnc-row-label-wide", children: row.label }),
1485
- /* @__PURE__ */ a2(StyleControls, { t: t3, row, onChange: (p3) => setStyles(styles.map((s3, j2) => j2 === i3 ? { ...s3, ...p3 } : s3)) })
1263
+ styles.map((row2, i3) => /* @__PURE__ */ a2("div", { class: "fnc-row", children: [
1264
+ /* @__PURE__ */ a2("span", { class: "fnc-row-label fnc-row-label-wide", children: row2.label }),
1265
+ /* @__PURE__ */ a2(StyleControls, { t: t3, row: row2, onChange: (p3) => setStyles(styles.map((s3, j2) => j2 === i3 ? { ...s3, ...p3 } : s3)) })
1486
1266
  ] }))
1487
1267
  ] }),
1488
1268
  /* @__PURE__ */ a2("div", { class: "fnc-form-actions", children: [
@@ -1603,19 +1383,216 @@ function IntervalPicker({ ctx }) {
1603
1383
  ] });
1604
1384
  }
1605
1385
 
1606
- // src/components/slot.tsx
1607
- function Slot({ el }) {
1608
- const ref = T2(null);
1609
- F2(() => {
1610
- ref.current?.appendChild(el);
1611
- return () => el.remove();
1612
- }, [el]);
1613
- return /* @__PURE__ */ a2("div", { class: "fnc-slot-host", ref });
1386
+ // src/layouts.ts
1387
+ var row = (...children) => ({ dir: "row", children });
1388
+ var col = (...children) => ({ dir: "col", children });
1389
+ var LAYOUTS = {
1390
+ "1": 0,
1391
+ "2-cols": row(0, 1),
1392
+ "2-rows": col(0, 1),
1393
+ "3-cols": row(0, 1, 2),
1394
+ "3-rows": col(0, 1, 2),
1395
+ "3-left": row(0, col(1, 2)),
1396
+ "3-top": col(0, row(1, 2)),
1397
+ "4-grid": col(row(0, 1), row(2, 3)),
1398
+ "4-cols": row(0, 1, 2, 3),
1399
+ "4-left": row(0, col(1, 2, 3)),
1400
+ "6-grid": col(row(0, 1, 2), row(3, 4, 5)),
1401
+ "8-grid": col(row(0, 1, 2, 3), row(4, 5, 6, 7))
1402
+ };
1403
+ var LAYOUT_GROUPS = [
1404
+ ["1"],
1405
+ ["2-cols", "2-rows"],
1406
+ ["3-cols", "3-rows", "3-left", "3-top"],
1407
+ ["4-grid", "4-cols", "4-left"],
1408
+ ["6-grid", "8-grid"]
1409
+ ];
1410
+ var MAX_PANES = 8;
1411
+ function isLayoutId(value) {
1412
+ return typeof value === "string" && value in LAYOUTS;
1413
+ }
1414
+ function paneCount(node) {
1415
+ if (typeof node === "number") return 1;
1416
+ let n2 = 0;
1417
+ for (const child of node.children) n2 += paneCount(child);
1418
+ return n2;
1419
+ }
1420
+ function sizesFor(sizes, key, count) {
1421
+ const s3 = sizes[key];
1422
+ return s3 && s3.length === count && s3.every((v3) => Number.isFinite(v3) && v3 > 0) ? s3 : Array(count).fill(1);
1423
+ }
1424
+ function resizePair(weights, i3, deltaFraction, min = 0.12) {
1425
+ const total = weights.reduce((a5, b2) => a5 + b2, 0);
1426
+ const a4 = weights[i3];
1427
+ const b = weights[i3 + 1];
1428
+ const pair = a4 + b;
1429
+ const delta = deltaFraction * total;
1430
+ const nextA = Math.min(pair * (1 - min), Math.max(pair * min, a4 + delta));
1431
+ const next = [...weights];
1432
+ next[i3] = nextA;
1433
+ next[i3 + 1] = pair - nextA;
1434
+ return next;
1435
+ }
1436
+ function layoutRects(node, x2 = 0, y3 = 0, w3 = 20, h3 = 16, gap = 1.6) {
1437
+ if (typeof node === "number") return [{ x: x2, y: y3, w: w3, h: h3 }];
1438
+ const n2 = node.children.length;
1439
+ const out = [];
1440
+ node.children.forEach((child, i3) => {
1441
+ if (node.dir === "row") {
1442
+ const cw = (w3 - gap * (n2 - 1)) / n2;
1443
+ out.push(...layoutRects(child, x2 + i3 * (cw + gap), y3, cw, h3, gap));
1444
+ } else {
1445
+ const ch = (h3 - gap * (n2 - 1)) / n2;
1446
+ out.push(...layoutRects(child, x2, y3 + i3 * (ch + gap), w3, ch, gap));
1447
+ }
1448
+ });
1449
+ return out;
1614
1450
  }
1615
1451
 
1616
- // src/components/symbol-search.tsx
1617
- import { formatPrice as formatPrice2 } from "@funnest/charts";
1618
- var DEBOUNCE_MS = 150;
1452
+ // src/components/layout-menu.tsx
1453
+ function LayoutGlyph({ id }) {
1454
+ return /* @__PURE__ */ a2("svg", { width: "20", height: "16", viewBox: "0 0 20 16", "aria-hidden": "true", children: layoutRects(LAYOUTS[id]).map((r3) => /* @__PURE__ */ a2("rect", { x: r3.x + 0.5, y: r3.y + 0.5, width: Math.max(0, r3.w - 1), height: Math.max(0, r3.h - 1), rx: "1.5", fill: "none", stroke: "currentColor", "stroke-width": "1.2" })) });
1455
+ }
1456
+ function usePopover() {
1457
+ const [open, setOpen] = d2(false);
1458
+ const ref = T2(null);
1459
+ A2(() => {
1460
+ if (!open) return;
1461
+ const onDown = (e3) => !ref.current?.contains(e3.target) && setOpen(false);
1462
+ const onKey = (e3) => e3.key === "Escape" && setOpen(false);
1463
+ document.addEventListener("pointerdown", onDown);
1464
+ document.addEventListener("keydown", onKey);
1465
+ return () => {
1466
+ document.removeEventListener("pointerdown", onDown);
1467
+ document.removeEventListener("keydown", onKey);
1468
+ };
1469
+ }, [open]);
1470
+ return { open, setOpen, ref };
1471
+ }
1472
+ var SYNC_KEYS = [
1473
+ { key: "crosshair", label: "syncCrosshair" },
1474
+ { key: "time", label: "syncTime" },
1475
+ { key: "symbol", label: "syncSymbol" },
1476
+ { key: "interval", label: "syncInterval" }
1477
+ ];
1478
+ function LayoutMenu({ ctx, multi }) {
1479
+ const s3 = useStore(ctx.store);
1480
+ const m3 = useStore(multi.store);
1481
+ const t3 = STRINGS[s3.locale];
1482
+ const layout = usePopover();
1483
+ const sync = usePopover();
1484
+ const anySync = Object.values(m3.sync).some(Boolean);
1485
+ return /* @__PURE__ */ a2($, { children: [
1486
+ /* @__PURE__ */ a2("div", { class: "fnc-menu-wrap", ref: layout.ref, children: [
1487
+ /* @__PURE__ */ a2(
1488
+ "button",
1489
+ {
1490
+ class: "fnc-btn fnc-btn-wide fnc-btn-layout",
1491
+ title: t3.layout,
1492
+ "aria-label": t3.layout,
1493
+ "aria-haspopup": "menu",
1494
+ "aria-expanded": layout.open,
1495
+ onClick: () => layout.setOpen(!layout.open),
1496
+ children: [
1497
+ /* @__PURE__ */ a2(LayoutGlyph, { id: m3.layout }),
1498
+ /* @__PURE__ */ a2(ChevronDownIcon, {})
1499
+ ]
1500
+ }
1501
+ ),
1502
+ layout.open && /* @__PURE__ */ a2("div", { class: "fnc-menu fnc-layout-menu", role: "menu", "aria-label": t3.layout, children: LAYOUT_GROUPS.map((group) => /* @__PURE__ */ a2("div", { class: "fnc-layout-row", children: [
1503
+ /* @__PURE__ */ a2("span", { class: "fnc-layout-count", children: group[0].split("-")[0] }),
1504
+ group.map((id) => /* @__PURE__ */ a2(
1505
+ "button",
1506
+ {
1507
+ class: "fnc-layout-opt",
1508
+ role: "menuitemradio",
1509
+ "aria-checked": id === m3.layout,
1510
+ "aria-label": `${t3.layout} ${id}`,
1511
+ title: id,
1512
+ onClick: () => {
1513
+ multi.setLayout(id);
1514
+ layout.setOpen(false);
1515
+ },
1516
+ children: /* @__PURE__ */ a2(LayoutGlyph, { id })
1517
+ }
1518
+ ))
1519
+ ] })) })
1520
+ ] }),
1521
+ /* @__PURE__ */ a2("div", { class: "fnc-menu-wrap", ref: sync.ref, children: [
1522
+ /* @__PURE__ */ a2(
1523
+ "button",
1524
+ {
1525
+ class: "fnc-btn fnc-btn-sync",
1526
+ title: t3.sync,
1527
+ "aria-label": t3.sync,
1528
+ "aria-haspopup": "menu",
1529
+ "aria-expanded": sync.open,
1530
+ "aria-pressed": anySync,
1531
+ onClick: () => sync.setOpen(!sync.open),
1532
+ children: /* @__PURE__ */ a2(LinkIcon, {})
1533
+ }
1534
+ ),
1535
+ sync.open && /* @__PURE__ */ a2("div", { class: "fnc-menu fnc-sync-menu", role: "menu", "aria-label": t3.sync, children: [
1536
+ /* @__PURE__ */ a2("div", { class: "fnc-menu-title", children: [
1537
+ t3.sync,
1538
+ /* @__PURE__ */ a2("small", { children: t3.syncHint })
1539
+ ] }),
1540
+ SYNC_KEYS.map(({ key, label }) => /* @__PURE__ */ a2(
1541
+ "button",
1542
+ {
1543
+ class: "fnc-menu-item fnc-sync-item",
1544
+ role: "menuitemcheckbox",
1545
+ "aria-checked": m3.sync[key],
1546
+ onClick: () => multi.setSync({ [key]: !m3.sync[key] }),
1547
+ children: [
1548
+ /* @__PURE__ */ a2("span", { children: t3[label] }),
1549
+ /* @__PURE__ */ a2("span", { class: "fnc-switch", "data-on": m3.sync[key] ? "true" : "false", "aria-hidden": "true" })
1550
+ ]
1551
+ }
1552
+ ))
1553
+ ] })
1554
+ ] })
1555
+ ] });
1556
+ }
1557
+
1558
+ // src/components/slot.tsx
1559
+ function Slot({ el, fixed = false }) {
1560
+ const ref = T2(null);
1561
+ F2(() => {
1562
+ ref.current?.appendChild(el);
1563
+ return () => el.remove();
1564
+ }, [el]);
1565
+ return /* @__PURE__ */ a2("div", { class: fixed ? "fnc-slot-host fnc-slot-host-fixed" : "fnc-slot-host", ref });
1566
+ }
1567
+
1568
+ // src/components/symbol-avatar.tsx
1569
+ function hue(text) {
1570
+ let h3 = 0;
1571
+ for (const ch of text) h3 = (h3 * 31 + ch.charCodeAt(0)) % 360;
1572
+ return h3;
1573
+ }
1574
+ function SymbolAvatar({ info, size = 24 }) {
1575
+ const [failed, setFailed] = d2(false);
1576
+ A2(() => setFailed(false), [info.logoUrl]);
1577
+ const style = { width: `${size}px`, height: `${size}px`, fontSize: `${Math.round(size * 0.46)}px` };
1578
+ if (info.logoUrl && !failed) {
1579
+ return /* @__PURE__ */ a2("img", { class: "fnc-avatar", src: info.logoUrl, alt: "", style, onError: () => setFailed(true) });
1580
+ }
1581
+ const h3 = hue(info.symbol);
1582
+ return /* @__PURE__ */ a2(
1583
+ "span",
1584
+ {
1585
+ class: "fnc-avatar fnc-avatar-initial",
1586
+ "aria-hidden": "true",
1587
+ style: { ...style, background: `hsl(${h3} 70% 50% / 0.16)`, color: `hsl(${h3} 60% 42%)` },
1588
+ children: (info.name || info.symbol).trim().charAt(0).toUpperCase()
1589
+ }
1590
+ );
1591
+ }
1592
+
1593
+ // src/components/symbol-search.tsx
1594
+ import { formatPrice } from "@funnest/charts";
1595
+ var DEBOUNCE_MS = 150;
1619
1596
  var RECENT_KEY = "recent-symbols";
1620
1597
  var FAVORITE_KEY = "favorite-symbols";
1621
1598
  var MAX_RECENT = 8;
@@ -1712,7 +1689,7 @@ function SymbolSearch({ ctx }) {
1712
1689
  else return;
1713
1690
  e3.preventDefault();
1714
1691
  };
1715
- const row = (info, index, isRecent) => {
1692
+ const row2 = (info, index, isRecent) => {
1716
1693
  const cat = categoryOf(info);
1717
1694
  const fav = isFavorite(info);
1718
1695
  const change = info.changePercent ?? 0;
@@ -1757,7 +1734,7 @@ function SymbolSearch({ ctx }) {
1757
1734
  ] }),
1758
1735
  info.sparkline && /* @__PURE__ */ a2(Sparkline, { values: info.sparkline, up }),
1759
1736
  info.price !== void 0 && /* @__PURE__ */ a2("span", { class: "fnc-sr-quote", children: [
1760
- /* @__PURE__ */ a2("b", { children: formatPrice2(info.price, info.pricePrecision) }),
1737
+ /* @__PURE__ */ a2("b", { children: formatPrice(info.price, info.pricePrecision) }),
1761
1738
  info.changePercent !== void 0 && /* @__PURE__ */ a2("span", { class: trend, children: [
1762
1739
  change > 0 ? "\u2191 " : change < 0 ? "\u2193 " : "",
1763
1740
  Math.abs(info.changePercent).toFixed(2),
@@ -1825,9 +1802,9 @@ function SymbolSearch({ ctx }) {
1825
1802
  /* @__PURE__ */ a2("span", { children: t3.recentSearches }),
1826
1803
  /* @__PURE__ */ a2("button", { class: "fnc-link", onClick: () => save(RECENT_KEY, [], setRecent), children: t3.clearRecent })
1827
1804
  ] }),
1828
- recentRows.map((info, i3) => row(info, i3, true)),
1805
+ recentRows.map((info, i3) => row2(info, i3, true)),
1829
1806
  (recentRows.length > 0 || view.kind === "favorites") && /* @__PURE__ */ a2("div", { class: "fnc-sr-section", children: view.kind === "favorites" ? t3.favorites : t3.symbols }),
1830
- loading && !results && view.kind === "all" ? Array.from({ length: 6 }, () => /* @__PURE__ */ a2("div", { class: "fnc-sr-skeleton", "aria-hidden": "true" })) : resultRows.map((info, i3) => row(info, recentRows.length + i3, false)),
1807
+ loading && !results && view.kind === "all" ? Array.from({ length: 6 }, () => /* @__PURE__ */ a2("div", { class: "fnc-sr-skeleton", "aria-hidden": "true" })) : resultRows.map((info, i3) => row2(info, recentRows.length + i3, false)),
1831
1808
  !loading && rows.length === 0 && /* @__PURE__ */ a2("div", { class: "fnc-empty", children: view.kind === "favorites" && favorites.length === 0 ? t3.noFavorites : t3.noResults })
1832
1809
  ] })
1833
1810
  ] }),
@@ -1900,6 +1877,7 @@ function Toolbar({ ctx }) {
1900
1877
  ]
1901
1878
  }
1902
1879
  ),
1880
+ ctx.multi && /* @__PURE__ */ a2(LayoutMenu, { ctx, multi: ctx.multi }),
1903
1881
  /* @__PURE__ */ a2("div", { class: "fnc-spacer" }),
1904
1882
  ctx.drawingToolbar && /* @__PURE__ */ a2(
1905
1883
  "button",
@@ -1912,7 +1890,7 @@ function Toolbar({ ctx }) {
1912
1890
  children: /* @__PURE__ */ a2(PencilIcon, {})
1913
1891
  }
1914
1892
  ),
1915
- /* @__PURE__ */ a2(Slot, { el: ctx.slots.toolbarEnd }),
1893
+ /* @__PURE__ */ a2(Slot, { el: ctx.slots.toolbarEnd, fixed: true }),
1916
1894
  /* @__PURE__ */ a2(
1917
1895
  "button",
1918
1896
  {
@@ -1940,126 +1918,382 @@ function Toolbar({ ctx }) {
1940
1918
  ] });
1941
1919
  }
1942
1920
 
1943
- // src/storage.ts
1944
- function createStorage(prefix) {
1945
- const memory = /* @__PURE__ */ new Map();
1946
- const ls = (() => {
1947
- try {
1948
- return prefix && typeof localStorage !== "undefined" ? localStorage : null;
1949
- } catch {
1950
- return null;
1951
- }
1952
- })();
1953
- const full = (key) => `${prefix}:${key}`;
1954
- return {
1955
- get(key, fallback) {
1956
- try {
1957
- const raw = ls ? ls.getItem(full(key)) : memory.get(key) ?? null;
1958
- return raw === null ? fallback : JSON.parse(raw);
1959
- } catch {
1960
- return fallback;
1961
- }
1962
- },
1963
- set(key, value) {
1964
- const raw = JSON.stringify(value);
1965
- try {
1966
- if (ls) ls.setItem(full(key), raw);
1967
- else memory.set(key, raw);
1968
- } catch {
1969
- memory.set(key, raw);
1970
- }
1971
- }
1972
- };
1973
- }
1974
-
1975
- // src/styles.ts
1976
- var CSS = (
1977
- /* css */
1978
- `
1979
- .fnc-root {
1980
- position: relative;
1981
- display: flex;
1982
- flex-direction: column;
1983
- width: 100%;
1984
- height: 100%;
1985
- min-height: 0;
1986
- background: var(--fnc-bg);
1987
- color: var(--fnc-text);
1988
- font-family: var(--fnc-font);
1989
- font-size: 14px;
1990
- line-height: 1.4;
1991
- -webkit-font-smoothing: antialiased;
1992
- overflow: hidden;
1993
- }
1994
- .fnc-root *, .fnc-root *::before, .fnc-root *::after { box-sizing: border-box; }
1995
- .fnc-root.fnc-pseudo-fullscreen { position: fixed; inset: 0; z-index: 2147483000; }
1996
- :where(.fnc-root) button {
1997
- font: inherit;
1998
- color: inherit;
1999
- background: none;
2000
- border: 0;
2001
- margin: 0;
2002
- padding: 0;
2003
- cursor: pointer;
2004
- -webkit-tap-highlight-color: transparent;
2005
- }
2006
- .fnc-root button:focus-visible { outline: 2px solid var(--fnc-accent); outline-offset: 1px; }
1921
+ // src/pane.tsx
1922
+ import { createChart } from "@funnest/charts";
2007
1923
 
2008
- /* \u2500\u2500 Toolbar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2009
- .fnc-toolbar {
2010
- display: flex;
2011
- align-items: center;
2012
- gap: 4px;
2013
- height: 48px;
2014
- padding: 0 8px;
2015
- border-bottom: 1px solid var(--fnc-border);
2016
- flex: none;
2017
- }
2018
- .fnc-symbol {
2019
- display: flex;
2020
- align-items: center;
2021
- gap: 6px;
2022
- height: 36px;
2023
- max-width: 220px;
2024
- padding: 0 10px 0 8px;
2025
- border-radius: var(--fnc-radius);
2026
- font-weight: 700;
2027
- font-size: 15px;
2028
- flex: none;
2029
- }
2030
- .fnc-symbol .fnc-icon { color: var(--fnc-text-2); display: flex; }
2031
- .fnc-symbol-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2032
- .fnc-symbol:not(:disabled):hover, .fnc-btn:hover, .fnc-chip:hover { background: var(--fnc-surface); }
2033
- .fnc-symbol:disabled { cursor: default; }
2034
- .fnc-sep { width: 1px; height: 20px; background: var(--fnc-border); margin: 0 4px; flex: none; }
2035
- .fnc-intervals {
2036
- display: flex;
2037
- gap: 2px;
2038
- overflow-x: auto;
2039
- scrollbar-width: none;
2040
- min-width: 0;
2041
- }
2042
- .fnc-intervals::-webkit-scrollbar { display: none; }
2043
- .fnc-chip {
2044
- height: 32px;
2045
- padding: 0 10px;
2046
- border-radius: var(--fnc-radius);
2047
- font-size: 13px;
2048
- font-weight: 600;
2049
- color: var(--fnc-text-2);
2050
- white-space: nowrap;
2051
- flex: none;
1924
+ // src/components/overlay.tsx
1925
+ import { formatCompact, formatPrice as formatPrice2 } from "@funnest/charts";
1926
+ var ZOOM_STEP = 1.25;
1927
+ var CONTROL_OFFSET = 52;
1928
+ var dirClass = (v3) => v3 > 0 ? "fnc-up" : v3 < 0 ? "fnc-down" : "fnc-flat";
1929
+ function IndicatorItem({ ctx, item }) {
1930
+ const t3 = STRINGS[ctx.store.get().locale];
1931
+ return /* @__PURE__ */ a2(
1932
+ "div",
1933
+ {
1934
+ class: item.visible ? "fnc-ind" : "fnc-ind fnc-ind-hidden",
1935
+ role: "button",
1936
+ tabIndex: 0,
1937
+ "aria-label": `${item.title} ${t3.indicatorSettings}`,
1938
+ onClick: (e3) => {
1939
+ if (e3.target.closest(".fnc-ind-actions")) return;
1940
+ ctx.store.set({ dialog: { kind: "indicatorSettings", id: item.id } });
1941
+ },
1942
+ onKeyDown: (e3) => e3.key === "Enter" && ctx.store.set({ dialog: { kind: "indicatorSettings", id: item.id } }),
1943
+ children: [
1944
+ /* @__PURE__ */ a2(
1945
+ "button",
1946
+ {
1947
+ class: "fnc-ind-btn fnc-ind-eye",
1948
+ title: item.visible ? t3.hide : t3.show,
1949
+ "aria-label": `${item.title} ${item.visible ? t3.hide : t3.show}`,
1950
+ "aria-pressed": !item.visible,
1951
+ onClick: (e3) => {
1952
+ e3.stopPropagation();
1953
+ ctx.chart.setIndicatorVisible(item.id, !item.visible);
1954
+ },
1955
+ children: item.visible ? /* @__PURE__ */ a2(EyeIcon, {}) : /* @__PURE__ */ a2(EyeOffIcon, {})
1956
+ }
1957
+ ),
1958
+ /* @__PURE__ */ a2("span", { class: "fnc-ind-title", children: item.title }),
1959
+ item.visible && item.values.map((v3) => /* @__PURE__ */ a2("b", { style: { color: v3.color }, title: v3.label, children: v3.value })),
1960
+ /* @__PURE__ */ a2("span", { class: "fnc-ind-actions", children: [
1961
+ /* @__PURE__ */ a2(
1962
+ "button",
1963
+ {
1964
+ class: "fnc-ind-btn",
1965
+ title: t3.indicatorSettings,
1966
+ "aria-label": `${item.title} ${t3.indicatorSettings}`,
1967
+ onClick: () => ctx.store.set({ dialog: { kind: "indicatorSettings", id: item.id } }),
1968
+ children: /* @__PURE__ */ a2(GearIcon, {})
1969
+ }
1970
+ ),
1971
+ /* @__PURE__ */ a2(
1972
+ "button",
1973
+ {
1974
+ class: "fnc-ind-btn",
1975
+ title: t3.removeIndicator,
1976
+ "aria-label": `${item.title} ${t3.removeIndicator}`,
1977
+ onClick: () => ctx.chart.removeIndicator(item.id),
1978
+ children: /* @__PURE__ */ a2(SmallCloseIcon, {})
1979
+ }
1980
+ )
1981
+ ] })
1982
+ ]
1983
+ }
1984
+ );
2052
1985
  }
2053
- .fnc-chip[aria-pressed='true'] { color: var(--fnc-text); background: var(--fnc-surface); }
2054
- .fnc-spacer { flex: 1; min-width: 8px; }
2055
- .fnc-btn {
2056
- display: flex;
2057
- align-items: center;
2058
- justify-content: center;
2059
- width: 36px;
2060
- height: 36px;
2061
- border-radius: var(--fnc-radius);
2062
- color: var(--fnc-text-2);
1986
+ function Legend({ ctx }) {
1987
+ const s3 = useStore(ctx.store);
1988
+ const t3 = STRINGS[s3.locale];
1989
+ const [hover, setHover] = d2(null);
1990
+ const [, refresh] = _2((n2) => n2 + 1, 0);
1991
+ const [collapsed, setCollapsed] = d2(false);
1992
+ A2(() => {
1993
+ const offs = [
1994
+ ctx.chart.on(
1995
+ "crosshairMove",
1996
+ (info) => setHover(info ? { bar: info.bar, prev: ctx.chart.bars[info.index - 1], index: info.index } : null)
1997
+ ),
1998
+ ctx.chart.on("dataChange", () => refresh(void 0))
1999
+ ];
2000
+ return () => offs.forEach((off) => off());
2001
+ }, [ctx.chart]);
2002
+ const ref = T2(null);
2003
+ A2(() => {
2004
+ const el = ref.current;
2005
+ if (!el) return;
2006
+ const update = () => ctx.chart.setTopInset(el.offsetTop + el.offsetHeight + 4);
2007
+ const ro = new ResizeObserver(update);
2008
+ ro.observe(el);
2009
+ return () => ro.disconnect();
2010
+ }, [ctx.chart]);
2011
+ if (!s3.symbol) return /* @__PURE__ */ a2("div", { class: "fnc-legend", ref });
2012
+ const bars = ctx.chart.bars;
2013
+ const bar = hover?.bar ?? bars[bars.length - 1];
2014
+ const prev = hover ? hover.prev : bars[bars.length - 2];
2015
+ const fmt = (p3) => formatPrice2(p3, s3.symbol.pricePrecision);
2016
+ let values = null;
2017
+ if (bar) {
2018
+ const cls = dirClass(bar.close - bar.open);
2019
+ const change = prev ? bar.close - prev.close : 0;
2020
+ const pct = prev && prev.close ? change / prev.close * 100 : 0;
2021
+ const sign = change > 0 ? "+" : "";
2022
+ values = /* @__PURE__ */ a2("div", { class: "fnc-legend-values", children: [
2023
+ /* @__PURE__ */ a2("span", { children: [
2024
+ t3.open,
2025
+ " ",
2026
+ /* @__PURE__ */ a2("b", { class: cls, children: fmt(bar.open) })
2027
+ ] }),
2028
+ /* @__PURE__ */ a2("span", { children: [
2029
+ t3.high,
2030
+ " ",
2031
+ /* @__PURE__ */ a2("b", { class: cls, children: fmt(bar.high) })
2032
+ ] }),
2033
+ /* @__PURE__ */ a2("span", { children: [
2034
+ t3.low,
2035
+ " ",
2036
+ /* @__PURE__ */ a2("b", { class: cls, children: fmt(bar.low) })
2037
+ ] }),
2038
+ /* @__PURE__ */ a2("span", { children: [
2039
+ t3.close_,
2040
+ " ",
2041
+ /* @__PURE__ */ a2("b", { class: cls, children: fmt(bar.close) })
2042
+ ] }),
2043
+ /* @__PURE__ */ a2("b", { class: dirClass(change), children: [
2044
+ sign,
2045
+ fmt(change),
2046
+ " (",
2047
+ sign,
2048
+ pct.toFixed(2),
2049
+ "%)"
2050
+ ] }),
2051
+ bar.volume !== void 0 && /* @__PURE__ */ a2("span", { children: [
2052
+ t3.volume,
2053
+ " ",
2054
+ /* @__PURE__ */ a2("b", { class: "fnc-flat", children: formatCompact(bar.volume, s3.locale) })
2055
+ ] })
2056
+ ] });
2057
+ }
2058
+ const items = s3.indicators.length ? ctx.chart.indicatorLegend(hover?.index) : [];
2059
+ const overlayItems = items.filter((i3) => i3.overlay);
2060
+ const paneLegends = s3.layout.panes.filter((p3) => p3.kind === "indicator").map((pane) => ({ pane, item: items.find((i3) => i3.id === pane.id) }));
2061
+ return /* @__PURE__ */ a2($, { children: [
2062
+ /* @__PURE__ */ a2("div", { class: "fnc-legend", ref, children: [
2063
+ /* @__PURE__ */ a2("div", { class: "fnc-legend-title", children: [
2064
+ ctx.showLogo && /* @__PURE__ */ a2(SymbolAvatar, { info: s3.symbol, size: 18 }),
2065
+ /* @__PURE__ */ a2("b", { children: s3.symbol.name }),
2066
+ /* @__PURE__ */ a2("span", { children: "\xB7" }),
2067
+ /* @__PURE__ */ a2("span", { children: resolutionLabel(s3.resolution, s3.locale) }),
2068
+ /* @__PURE__ */ a2("span", { children: "\xB7" }),
2069
+ /* @__PURE__ */ a2("span", { children: s3.symbol.exchange }),
2070
+ s3.loading && /* @__PURE__ */ a2("span", { class: "fnc-spinner", role: "status" })
2071
+ ] }),
2072
+ values,
2073
+ !collapsed && overlayItems.map((item) => /* @__PURE__ */ a2(IndicatorItem, { ctx, item })),
2074
+ overlayItems.length > 0 && (collapsed ? /* @__PURE__ */ a2("button", { class: "fnc-ind-handle", "aria-expanded": "false", "aria-label": t3.expand, title: t3.expand, onClick: () => setCollapsed(false), children: /* @__PURE__ */ a2("span", {}) }) : /* @__PURE__ */ a2("button", { class: "fnc-ind-collapse", "aria-expanded": "true", "aria-label": t3.collapse, title: t3.collapse, onClick: () => setCollapsed(true), children: /* @__PURE__ */ a2(CollapseLeftIcon, {}) }))
2075
+ ] }),
2076
+ paneLegends.map(
2077
+ ({ pane, item }) => item && /* @__PURE__ */ a2("div", { class: "fnc-legend fnc-pane-legend", style: { top: `${pane.top + 4}px` }, children: /* @__PURE__ */ a2(IndicatorItem, { ctx, item }) })
2078
+ )
2079
+ ] });
2080
+ }
2081
+ function NavControls({ ctx }) {
2082
+ const s3 = useStore(ctx.store);
2083
+ const t3 = STRINGS[s3.locale];
2084
+ const [showButtons, setShowButtons] = d2(ctx.chart.settings.navigationButtons);
2085
+ A2(() => ctx.chart.on("settingsChange", (settings) => setShowButtons(settings.navigationButtons)), [ctx.chart]);
2086
+ const { plotWidth, panesHeight } = s3.layout;
2087
+ if (plotWidth <= 0) return null;
2088
+ const top = panesHeight - CONTROL_OFFSET;
2089
+ const page = () => Math.max(1, Math.round(plotWidth / ctx.chart.timeScale.barSpacing / 3));
2090
+ const button = (label, onClick, icon) => /* @__PURE__ */ a2("button", { class: "fnc-btn", title: label, "aria-label": label, onClick, children: icon });
2091
+ return /* @__PURE__ */ a2($, { children: [
2092
+ showButtons && /* @__PURE__ */ a2("div", { class: "fnc-nav", style: { left: `${plotWidth / 2}px`, top: `${top}px` }, children: [
2093
+ button(t3.zoomOut, () => ctx.chart.zoom(1 / ZOOM_STEP), /* @__PURE__ */ a2(MinusIcon, {})),
2094
+ button(t3.zoomIn, () => ctx.chart.zoom(ZOOM_STEP), /* @__PURE__ */ a2(PlusIcon, {})),
2095
+ button(t3.scrollBack, () => ctx.chart.scrollBars(-page()), /* @__PURE__ */ a2(ChevronLeftIcon, {})),
2096
+ button(t3.scrollForward, () => ctx.chart.scrollBars(page()), /* @__PURE__ */ a2(ChevronRightIcon, {})),
2097
+ button(t3.resetView, () => ctx.chart.resetView(), /* @__PURE__ */ a2(ResetIcon, {}))
2098
+ ] }),
2099
+ !s3.atRealtime && /* @__PURE__ */ a2(
2100
+ "button",
2101
+ {
2102
+ class: "fnc-realtime",
2103
+ style: { left: `${plotWidth - 44}px`, top: `${top}px` },
2104
+ title: t3.toRealtime,
2105
+ "aria-label": t3.toRealtime,
2106
+ onClick: () => ctx.chart.scrollToRealtime(),
2107
+ children: /* @__PURE__ */ a2(ToRealtimeIcon, {})
2108
+ }
2109
+ )
2110
+ ] });
2111
+ }
2112
+ var ATTRIBUTION_URL = "https://charts.funnest.xyz/?utm_source=chart&utm_medium=attribution";
2113
+ function Attribution({ ctx }) {
2114
+ const s3 = useStore(ctx.store);
2115
+ const { panesHeight, plotWidth } = s3.layout;
2116
+ if (!ctx.attribution || plotWidth <= 0) return null;
2117
+ const label = s3.locale === "ko" ? "Funnest \uCC28\uD2B8" : "Charts by Funnest";
2118
+ return /* @__PURE__ */ a2(
2119
+ "a",
2120
+ {
2121
+ class: "fnc-attribution",
2122
+ href: ATTRIBUTION_URL,
2123
+ target: "_blank",
2124
+ rel: "noopener",
2125
+ title: label,
2126
+ "aria-label": label,
2127
+ style: { top: `${panesHeight - 36}px` },
2128
+ children: [
2129
+ /* @__PURE__ */ a2(FunnestMark, {}),
2130
+ /* @__PURE__ */ a2("span", { children: "funnest" })
2131
+ ]
2132
+ }
2133
+ );
2134
+ }
2135
+ function PaneTools({ ctx }) {
2136
+ return ctx.multi ? /* @__PURE__ */ a2(PaneMaximize, { ctx, multi: ctx.multi }) : null;
2137
+ }
2138
+ function PaneMaximize({ ctx, multi }) {
2139
+ const s3 = useStore(ctx.store);
2140
+ const m3 = useStore(multi.store);
2141
+ if (s3.layout.plotWidth <= 0 || m3.narrow || m3.layout === "1" && m3.maximized === null) return null;
2142
+ const t3 = STRINGS[s3.locale];
2143
+ const maximized = m3.maximized !== null;
2144
+ const label = maximized ? t3.restorePane : t3.maximizePane;
2145
+ return /* @__PURE__ */ a2(
2146
+ "button",
2147
+ {
2148
+ class: "fnc-btn fnc-pane-max",
2149
+ style: { left: `${s3.layout.plotWidth - 36}px` },
2150
+ title: `${label} (Alt+Enter)`,
2151
+ "aria-label": label,
2152
+ "aria-pressed": maximized,
2153
+ onClick: () => {
2154
+ const index = multi.store.get().active;
2155
+ multi.toggleMaximize(index);
2156
+ },
2157
+ children: maximized ? /* @__PURE__ */ a2(RestoreIcon, {}) : /* @__PURE__ */ a2(MaximizeIcon, {})
2158
+ }
2159
+ );
2160
+ }
2161
+ function Overlay({ ctx }) {
2162
+ return /* @__PURE__ */ a2($, { children: [
2163
+ /* @__PURE__ */ a2(Attribution, { ctx }),
2164
+ /* @__PURE__ */ a2(Legend, { ctx }),
2165
+ /* @__PURE__ */ a2(NavControls, { ctx }),
2166
+ /* @__PURE__ */ a2(SelectionBar, { ctx }),
2167
+ /* @__PURE__ */ a2(PaneTools, { ctx })
2168
+ ] });
2169
+ }
2170
+
2171
+ // src/shell.ts
2172
+ import { darkTheme, lightTheme } from "@funnest/charts";
2173
+
2174
+ // src/storage.ts
2175
+ function createStorage(prefix) {
2176
+ const memory = /* @__PURE__ */ new Map();
2177
+ const ls = (() => {
2178
+ try {
2179
+ return prefix && typeof localStorage !== "undefined" ? localStorage : null;
2180
+ } catch {
2181
+ return null;
2182
+ }
2183
+ })();
2184
+ const full = (key) => `${prefix}:${key}`;
2185
+ return {
2186
+ get(key, fallback) {
2187
+ try {
2188
+ const raw = ls ? ls.getItem(full(key)) : memory.get(key) ?? null;
2189
+ return raw === null ? fallback : JSON.parse(raw);
2190
+ } catch {
2191
+ return fallback;
2192
+ }
2193
+ },
2194
+ set(key, value) {
2195
+ const raw = JSON.stringify(value);
2196
+ try {
2197
+ if (ls) ls.setItem(full(key), raw);
2198
+ else memory.set(key, raw);
2199
+ } catch {
2200
+ memory.set(key, raw);
2201
+ }
2202
+ }
2203
+ };
2204
+ }
2205
+
2206
+ // src/styles.ts
2207
+ var CSS = (
2208
+ /* css */
2209
+ `
2210
+ .fnc-root {
2211
+ position: relative;
2212
+ display: flex;
2213
+ flex-direction: column;
2214
+ width: 100%;
2215
+ height: 100%;
2216
+ min-height: 0;
2217
+ background: var(--fnc-bg);
2218
+ color: var(--fnc-text);
2219
+ font-family: var(--fnc-font);
2220
+ font-size: 14px;
2221
+ line-height: 1.4;
2222
+ -webkit-font-smoothing: antialiased;
2223
+ overflow: hidden;
2224
+ }
2225
+ .fnc-root *, .fnc-root *::before, .fnc-root *::after { box-sizing: border-box; }
2226
+ .fnc-root.fnc-pseudo-fullscreen { position: fixed; inset: 0; z-index: 2147483000; }
2227
+ :where(.fnc-root) button {
2228
+ font: inherit;
2229
+ color: inherit;
2230
+ background: none;
2231
+ border: 0;
2232
+ margin: 0;
2233
+ padding: 0;
2234
+ cursor: pointer;
2235
+ -webkit-tap-highlight-color: transparent;
2236
+ }
2237
+ .fnc-root button:focus-visible { outline: 2px solid var(--fnc-accent); outline-offset: 1px; }
2238
+
2239
+ /* \u2500\u2500 Toolbar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2240
+ .fnc-toolbar {
2241
+ display: flex;
2242
+ align-items: center;
2243
+ gap: 4px;
2244
+ height: 48px;
2245
+ padding: 0 8px;
2246
+ border-bottom: 1px solid var(--fnc-border);
2247
+ flex: none;
2248
+ }
2249
+ .fnc-symbol {
2250
+ display: flex;
2251
+ align-items: center;
2252
+ gap: 6px;
2253
+ height: 36px;
2254
+ max-width: 220px;
2255
+ padding: 0 10px 0 8px;
2256
+ border-radius: var(--fnc-radius);
2257
+ font-weight: 700;
2258
+ font-size: 15px;
2259
+ /* The symbol gives way first when the toolbar is tight: its name truncates, never overlaps. */
2260
+ flex: 0 1 auto;
2261
+ min-width: 36px;
2262
+ overflow: hidden;
2263
+ }
2264
+ .fnc-symbol .fnc-icon { color: var(--fnc-text-2); display: flex; }
2265
+ .fnc-symbol-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2266
+ .fnc-symbol:not(:disabled):hover, .fnc-btn:hover, .fnc-chip:hover { background: var(--fnc-surface); }
2267
+ .fnc-symbol:disabled { cursor: default; }
2268
+ .fnc-sep { width: 1px; height: 20px; background: var(--fnc-border); margin: 0 4px; flex: none; }
2269
+ .fnc-intervals {
2270
+ display: flex;
2271
+ gap: 2px;
2272
+ overflow-x: auto;
2273
+ scrollbar-width: none;
2274
+ min-width: 0;
2275
+ }
2276
+ .fnc-intervals::-webkit-scrollbar { display: none; }
2277
+ .fnc-chip {
2278
+ height: 32px;
2279
+ padding: 0 10px;
2280
+ border-radius: var(--fnc-radius);
2281
+ font-size: 13px;
2282
+ font-weight: 600;
2283
+ color: var(--fnc-text-2);
2284
+ white-space: nowrap;
2285
+ flex: none;
2286
+ }
2287
+ .fnc-chip[aria-pressed='true'] { color: var(--fnc-text); background: var(--fnc-surface); }
2288
+ .fnc-spacer { flex: 1; min-width: 8px; }
2289
+ .fnc-btn {
2290
+ display: flex;
2291
+ align-items: center;
2292
+ justify-content: center;
2293
+ width: 36px;
2294
+ height: 36px;
2295
+ border-radius: var(--fnc-radius);
2296
+ color: var(--fnc-text-2);
2063
2297
  flex: none;
2064
2298
  }
2065
2299
  .fnc-btn:hover { color: var(--fnc-text); }
@@ -2111,7 +2345,9 @@ var CSS = (
2111
2345
 
2112
2346
  /* \u2500\u2500 Chart body & overlays \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2113
2347
  .fnc-body { position: relative; display: flex; flex: 1; min-height: 0; }
2114
- .fnc-stage { position: relative; flex: 1; min-width: 0; }
2348
+ .fnc-main { position: relative; display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; }
2349
+ .fnc-stage { position: relative; flex: 1; min-width: 0; min-height: 0; }
2350
+ .fnc-slot-bottom { flex: none; min-width: 0; }
2115
2351
  .fnc-chart-host, .fnc-overlay { position: absolute; inset: 0; }
2116
2352
 
2117
2353
  /* \u2500\u2500 Drawing tool bar (left) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
@@ -2278,6 +2514,8 @@ var CSS = (
2278
2514
  /* \u2500\u2500 Slots: host-app UI inside the widget (collapse when empty) \u2500\u2500 */
2279
2515
  .fnc-slot:empty { display: none; }
2280
2516
  .fnc-slot-host { display: flex; align-self: stretch; min-width: 0; flex: 0 1 auto; }
2517
+ /* Buttons next to the gear keep their size (they'd otherwise slide under it on phones). */
2518
+ .fnc-slot-host-fixed { flex: none; }
2281
2519
  .fnc-slot-toolbar { position: relative; display: flex; align-items: center; min-width: 0; padding-left: 9px; margin-left: 4px; }
2282
2520
  .fnc-slot-toolbar::before {
2283
2521
  content: '';
@@ -2686,13 +2924,124 @@ var CSS = (
2686
2924
  .fnc-action-primary { background: var(--fnc-accent); color: #fff; }
2687
2925
  .fnc-action-danger { flex: 0 0 auto; padding: 0 16px; color: var(--fnc-danger, #F04452); background: none; }
2688
2926
 
2927
+ /* \u2500\u2500 Narrow phones (\u2264 400px): drop the menu chevrons and separators so every button fits \u2500\u2500 */
2928
+ @media (max-width: 400px) {
2929
+ .fnc-toolbar .fnc-btn-wide > svg:last-child { display: none; }
2930
+ .fnc-toolbar .fnc-btn-wide { padding: 0 3px; }
2931
+ .fnc-toolbar > .fnc-sep { display: none; }
2932
+ .fnc-toolbar > .fnc-spacer { min-width: 0; }
2933
+ }
2934
+
2935
+ /* \u2500\u2500 Multi-chart (split panes) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2936
+ .fnc-grid { display: flex; flex: 1; min-width: 0; min-height: 0; }
2937
+ .fnc-split { display: flex; min-width: 0; min-height: 0; }
2938
+ .fnc-split-row { flex-direction: row; }
2939
+ .fnc-split-col { flex-direction: column; }
2940
+ .fnc-pane { position: relative; display: flex; min-width: 0; min-height: 0; }
2941
+ .fnc-pane > .fnc-stage { flex: 1; min-height: 0; }
2942
+ .fnc-split-on .fnc-pane-active::after {
2943
+ content: '';
2944
+ position: absolute;
2945
+ inset: 0;
2946
+ border: 1px solid var(--fnc-accent);
2947
+ pointer-events: none;
2948
+ z-index: 3;
2949
+ }
2950
+ .fnc-splitter { position: relative; flex: none; background: var(--fnc-border); z-index: 4; touch-action: none; }
2951
+ .fnc-splitter::after { content: ''; position: absolute; }
2952
+ .fnc-splitter-row { width: 1px; cursor: col-resize; }
2953
+ .fnc-splitter-row::after { inset: 0 -4px; }
2954
+ .fnc-splitter-col { height: 1px; cursor: row-resize; }
2955
+ .fnc-splitter-col::after { inset: -4px 0; }
2956
+ .fnc-splitter:hover { background: var(--fnc-accent); }
2957
+ .fnc-resizing-x, .fnc-resizing-x * { cursor: col-resize !important; user-select: none; }
2958
+ .fnc-resizing-y, .fnc-resizing-y * { cursor: row-resize !important; user-select: none; }
2959
+ .fnc-parking { display: none; }
2960
+ /* Navigation controls follow the hovered pane, not the whole widget. */
2961
+ .fnc-multi .fnc-body:hover .fnc-nav { opacity: 0; }
2962
+ .fnc-multi .fnc-stage:hover .fnc-nav, .fnc-multi .fnc-nav:focus-within { opacity: 1; }
2963
+ .fnc-pane-max {
2964
+ position: absolute;
2965
+ top: 6px;
2966
+ width: 28px;
2967
+ height: 28px;
2968
+ opacity: 0;
2969
+ background: var(--fnc-bg);
2970
+ pointer-events: auto;
2971
+ z-index: 2;
2972
+ }
2973
+ .fnc-stage:hover .fnc-pane-max, .fnc-pane-max:focus-visible, .fnc-pane-max[aria-pressed='true'] { opacity: 1; }
2974
+ @media (hover: none) { .fnc-pane-max { opacity: 0.7; } }
2975
+ .fnc-pane-tabs {
2976
+ display: flex;
2977
+ gap: 4px;
2978
+ padding: 4px 8px;
2979
+ overflow-x: auto;
2980
+ scrollbar-width: none;
2981
+ border-bottom: 1px solid var(--fnc-border);
2982
+ }
2983
+ .fnc-pane-tab {
2984
+ display: flex;
2985
+ align-items: center;
2986
+ gap: 6px;
2987
+ flex: none;
2988
+ height: 30px;
2989
+ padding: 0 10px 0 6px;
2990
+ border-radius: var(--fnc-radius);
2991
+ font-size: 13px;
2992
+ font-weight: 600;
2993
+ color: var(--fnc-text-2);
2994
+ white-space: nowrap;
2995
+ }
2996
+ .fnc-pane-tab small { font-size: 12px; font-weight: 400; color: var(--fnc-text-2); }
2997
+ .fnc-pane-tab[aria-selected='true'] { background: var(--fnc-surface); color: var(--fnc-text); }
2998
+ .fnc-pane-tab-num {
2999
+ display: grid;
3000
+ place-items: center;
3001
+ width: 18px;
3002
+ height: 18px;
3003
+ border-radius: 5px;
3004
+ background: var(--fnc-surface);
3005
+ font-size: 11px;
3006
+ }
3007
+ .fnc-pane-tab[aria-selected='true'] .fnc-pane-tab-num { background: var(--fnc-accent); color: #fff; }
3008
+ .fnc-layout-menu { min-width: 0; gap: 2px; padding: 8px; }
3009
+ .fnc-layout-row { display: flex; align-items: center; gap: 2px; }
3010
+ .fnc-layout-count { width: 16px; flex: none; font-size: 12px; color: var(--fnc-text-2); text-align: center; }
3011
+ .fnc-layout-opt {
3012
+ display: grid;
3013
+ place-items: center;
3014
+ width: 36px;
3015
+ height: 32px;
3016
+ border-radius: var(--fnc-radius);
3017
+ color: var(--fnc-text-2);
3018
+ }
3019
+ .fnc-layout-opt:hover { background: var(--fnc-surface); color: var(--fnc-text); }
3020
+ .fnc-layout-opt[aria-checked='true'] { background: var(--fnc-surface); color: var(--fnc-accent); }
3021
+ .fnc-sync-menu { min-width: 220px; }
3022
+ .fnc-menu-title { display: flex; flex-direction: column; gap: 2px; padding: 6px 10px 8px; font-size: 13px; font-weight: 600; color: var(--fnc-text); }
3023
+ .fnc-menu-title small { font-size: 12px; font-weight: 400; color: var(--fnc-text-2); }
3024
+ .fnc-sync-item { justify-content: space-between; }
3025
+ .fnc-sync-item[aria-checked='true'] { background: none; color: var(--fnc-text); }
3026
+ .fnc-sync-item:hover { background: var(--fnc-surface); }
3027
+ .fnc-sync-item .fnc-switch { display: inline-block; transform: scale(0.8); transform-origin: right center; }
3028
+ .fnc-switch[data-on='true'] { background: var(--fnc-accent); }
3029
+ .fnc-switch[data-on='true']::after { transform: translateX(16px); }
3030
+ .fnc-btn-sync[aria-pressed='true'] { color: var(--fnc-accent); }
3031
+
2689
3032
  /* \u2500\u2500 Mobile \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2690
3033
  @media (max-width: 640px) {
2691
- .fnc-toolbar { padding: 0 4px; }
3034
+ .fnc-toolbar { padding: 0 4px; gap: 2px; }
3035
+ .fnc-toolbar .fnc-btn { width: 32px; height: 32px; }
3036
+ .fnc-toolbar .fnc-btn-wide, .fnc-toolbar .fnc-btn-label { width: auto; padding: 0 6px; }
3037
+ .fnc-toolbar .fnc-sep { margin: 0 2px; }
2692
3038
  .fnc-symbol { max-width: 140px; padding: 0 8px 0 6px; }
3039
+ /* Logo only on phones; the name is in the chart legend right below. */
3040
+ .fnc-toolbar .fnc-symbol:has(.fnc-avatar) :is(.fnc-symbol-name, .fnc-icon) { display: none; }
2693
3041
  .fnc-chip { padding: 0 8px; }
2694
3042
  .fnc-btn-screenshot { display: none; }
2695
3043
  .fnc-btn-label span { display: none; }
3044
+ .fnc-btn-sync { display: none; }
2696
3045
  .fnc-intervals { display: none; }
2697
3046
  .fnc-interval-current { display: inline; }
2698
3047
  .fnc-interval-menu { left: auto; right: -40px; }
@@ -2715,7 +3064,7 @@ var CSS = (
2715
3064
  .fnc-dialog.fnc-search-dialog { width: 100%; height: 100%; }
2716
3065
  .fnc-sr-footer, .fnc-sr-side, .fnc-hide-mobile, .fnc-sr-spark { display: none; }
2717
3066
  /* The interval button must stay whole; the symbol name truncates instead. */
2718
- .fnc-symbol { flex: 0 1 auto; min-width: 0; }
3067
+ .fnc-symbol { flex: 0 1 auto; min-width: 36px; }
2719
3068
  /* MA rows wrap to two lines: [label \u2713 color width \xD7] / [source period] */
2720
3069
  .fnc-rows .fnc-row { flex-wrap: wrap; row-gap: 8px; padding-bottom: 10px; border-bottom: 1px solid var(--fnc-border); }
2721
3070
  /* Basis = row \u2212 indent(52) \u2212 gap(8) \u2212 period(72): too wide for line 1, so it always wraps. */
@@ -2734,21 +3083,7 @@ function injectStyles(doc = document) {
2734
3083
  doc.head.appendChild(style);
2735
3084
  }
2736
3085
 
2737
- // src/trading-chart.tsx
2738
- var DEFAULT_INTERVAL_GROUPS = [
2739
- { id: "minutes", intervals: ["1", "5", "15", "30"] },
2740
- { id: "hours", intervals: ["60", "240"] },
2741
- { id: "days", intervals: ["1D", "1W", "1M", "3M", "6M", "12M"] }
2742
- ];
2743
- var DEFAULT_FAVORITE_INTERVALS = ["1", "5", "15", "60", "240", "1D"];
2744
- var stockLike = (i3) => i3.type === "stock" || i3.type === "etf" || i3.type === "index";
2745
- var DEFAULT_SEARCH_CATEGORIES = [
2746
- { id: "all", label: { ko: "\uC804\uCCB4", en: "All" }, match: () => true },
2747
- { id: "kr-stock", label: { ko: "\uAD6D\uB0B4\uC8FC\uC2DD", en: "KR stocks" }, match: (i3) => stockLike(i3) && i3.country === "KR" },
2748
- { id: "global-stock", label: { ko: "\uD574\uC678\uC8FC\uC2DD", en: "Global stocks" }, match: (i3) => stockLike(i3) && i3.country !== "KR" },
2749
- { id: "crypto", label: { ko: "\uCF54\uC778", en: "Crypto" }, match: (i3) => i3.type === "crypto" },
2750
- { id: "futures", label: { ko: "\uC120\uBB3C", en: "Futures" }, match: (i3) => i3.type === "futures" }
2751
- ];
3086
+ // src/shell.ts
2752
3087
  var THEME_VARS = {
2753
3088
  background: "--fnc-bg",
2754
3089
  surface: "--fnc-surface",
@@ -2767,37 +3102,19 @@ function applyThemeVars(el, theme) {
2767
3102
  el.style.setProperty("--fnc-radius", `${theme.radius}px`);
2768
3103
  }
2769
3104
  function div(className, parent) {
2770
- const el = document.createElement("div");
2771
- el.className = className;
2772
- parent?.appendChild(el);
2773
- return el;
2774
- }
2775
- function createTradingChart(container, options) {
2776
- const {
2777
- datafeed,
2778
- symbol,
2779
- resolution = "1D",
2780
- intervalGroups = DEFAULT_INTERVAL_GROUPS,
2781
- favoriteIntervals,
2782
- onFavoriteIntervalsChange,
2783
- searchCategories = DEFAULT_SEARCH_CATEGORIES,
2784
- storageKey = "funnest-charts",
2785
- searchHotkey = true,
2786
- symbolLogo = true,
2787
- drawingToolbar = true,
2788
- chartType = "candles",
2789
- attribution = true,
2790
- colorScheme = "system",
2791
- ...chartOptions
2792
- } = options;
2793
- const locale = chartOptions.locale ?? "ko";
2794
- const displayTimezone = chartOptions.displayTimezone ?? (locale === "ko" ? "Asia/Seoul" : Intl.DateTimeFormat().resolvedOptions().timeZone);
2795
- const storage = createStorage(storageKey);
3105
+ const el = document.createElement("div");
3106
+ el.className = className;
3107
+ parent?.appendChild(el);
3108
+ return el;
3109
+ }
3110
+ function createShell(container, options) {
2796
3111
  injectStyles(container.ownerDocument);
3112
+ const storage = createStorage(options.storageKey);
2797
3113
  const win = container.ownerDocument.defaultView;
2798
3114
  const darkQuery = win?.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
2799
- let scheme = colorScheme;
2800
- let themePatch = { ...chartOptions.theme };
3115
+ let scheme = options.colorScheme;
3116
+ let themePatch = { ...options.theme };
3117
+ let panes = () => [];
2801
3118
  const isDark = () => scheme === "system" ? !!darkQuery?.matches : scheme === "dark";
2802
3119
  const resolveTheme = () => ({ ...isDark() ? darkTheme : lightTheme, ...themePatch });
2803
3120
  let theme = resolveTheme();
@@ -2813,33 +3130,138 @@ function createTradingChart(container, options) {
2813
3130
  toolbar: div("fnc-slot fnc-slot-toolbar"),
2814
3131
  toolbarEnd: div("fnc-slot fnc-slot-toolbar-end"),
2815
3132
  belowToolbar: div("fnc-slot fnc-slot-below-toolbar", root),
3133
+ bottom: div("fnc-slot fnc-slot-bottom"),
2816
3134
  footer: div("fnc-slot fnc-slot-footer")
2817
3135
  };
2818
3136
  const body = div("fnc-body", root);
2819
- const drawbarHost = drawingToolbar ? div("fnc-drawbar-host", body) : null;
2820
- const stage = div("fnc-stage", body);
2821
- const chartHost = div("fnc-chart-host", stage);
2822
- const overlayHost = div("fnc-overlay", stage);
3137
+ const drawbarHost = options.drawingToolbar ? div("fnc-drawbar-host", body) : null;
3138
+ const main = div("fnc-main", body);
3139
+ main.appendChild(slots.bottom);
2823
3140
  root.appendChild(slots.footer);
2824
- const chart = createChart(chartHost, { ...chartOptions, theme, datafeed, displayTimezone });
2825
3141
  const refreshScheme = () => {
2826
3142
  theme = resolveTheme();
2827
3143
  applyScheme();
2828
- chart.setTheme(theme);
3144
+ for (const p3 of panes()) p3.chart.setTheme(theme);
2829
3145
  };
2830
3146
  const onSchemeChange = () => scheme === "system" && refreshScheme();
2831
3147
  darkQuery?.addEventListener?.("change", onSchemeChange);
2832
- chart.setChartType(chartType);
3148
+ let favoriteIntervals = storage.get("favorite-intervals", [...options.favoriteIntervals]);
3149
+ let drawbarCollapsed = storage.get("drawbar-collapsed", false);
3150
+ const broadcast = (patch) => {
3151
+ for (const p3 of panes()) p3.store.set(patch);
3152
+ };
3153
+ const isNativeFullscreen = () => document.fullscreenElement === root;
3154
+ const isFullscreen = () => isNativeFullscreen() || root.classList.contains("fnc-pseudo-fullscreen");
3155
+ const onFullscreenChange = () => broadcast({ fullscreen: isFullscreen() });
3156
+ document.addEventListener("fullscreenchange", onFullscreenChange);
3157
+ const pseudo = (on) => {
3158
+ root.classList.toggle("fnc-pseudo-fullscreen", on);
3159
+ onFullscreenChange();
3160
+ };
3161
+ return {
3162
+ root,
3163
+ toolbarHost,
3164
+ body,
3165
+ drawbarHost,
3166
+ main,
3167
+ slots,
3168
+ storage,
3169
+ theme: () => theme,
3170
+ sharedState: () => ({ fullscreen: isFullscreen(), favoriteIntervals, drawbarCollapsed }),
3171
+ setPanes: (get) => {
3172
+ panes = get;
3173
+ },
3174
+ setTheme: (patch) => {
3175
+ themePatch = { ...themePatch, ...patch };
3176
+ refreshScheme();
3177
+ },
3178
+ setColorScheme: (next) => {
3179
+ scheme = next;
3180
+ refreshScheme();
3181
+ },
3182
+ isFullscreen,
3183
+ toggleFullscreen: () => {
3184
+ if (isNativeFullscreen()) void document.exitFullscreen();
3185
+ else if (root.classList.contains("fnc-pseudo-fullscreen")) pseudo(false);
3186
+ else if (root.requestFullscreen && document.fullscreenEnabled) root.requestFullscreen().catch(() => pseudo(true));
3187
+ else pseudo(true);
3188
+ },
3189
+ setDrawbarCollapsed: (collapsed) => {
3190
+ drawbarCollapsed = collapsed;
3191
+ storage.set("drawbar-collapsed", collapsed);
3192
+ broadcast({ drawbarCollapsed });
3193
+ },
3194
+ toggleFavoriteInterval: (res) => {
3195
+ favoriteIntervals = favoriteIntervals.includes(res) ? favoriteIntervals.filter((r3) => r3 !== res) : [...favoriteIntervals, res];
3196
+ storage.set("favorite-intervals", favoriteIntervals);
3197
+ broadcast({ favoriteIntervals });
3198
+ options.onFavoriteIntervalsChange?.(favoriteIntervals);
3199
+ },
3200
+ destroy: () => {
3201
+ document.removeEventListener("fullscreenchange", onFullscreenChange);
3202
+ darkQuery?.removeEventListener?.("change", onSchemeChange);
3203
+ if (isNativeFullscreen()) void document.exitFullscreen();
3204
+ root.remove();
3205
+ }
3206
+ };
3207
+ }
3208
+ function bindWidgetKeys(shell, active, searchHotkey, extra) {
3209
+ const { root } = shell;
3210
+ const onKeyDown = (e3) => {
3211
+ const pane = active();
3212
+ if (!pane) return;
3213
+ const s3 = pane.store.get();
3214
+ if (searchHotkey && (e3.ctrlKey || e3.metaKey) && e3.key.toLowerCase() === "k" && pane.canSearch && !s3.dialog) {
3215
+ e3.preventDefault();
3216
+ pane.store.set({ searchOpen: true });
3217
+ return;
3218
+ }
3219
+ if (e3.key === "Escape" && !e3.defaultPrevented && (s3.searchOpen || s3.dialog)) {
3220
+ e3.preventDefault();
3221
+ pane.store.set(s3.searchOpen ? { searchOpen: false } : { dialog: null });
3222
+ return;
3223
+ }
3224
+ if (e3.key === "Escape" && root.classList.contains("fnc-pseudo-fullscreen") && !s3.searchOpen && !s3.dialog) {
3225
+ shell.toggleFullscreen();
3226
+ return;
3227
+ }
3228
+ if (extra?.(e3)) e3.preventDefault();
3229
+ };
3230
+ const onKeyUp = (e3) => {
3231
+ const pane = active();
3232
+ if (!pane) return;
3233
+ const s3 = pane.store.get();
3234
+ if (e3.key === "Escape" && (s3.searchOpen || s3.dialog)) pane.store.set(s3.searchOpen ? { searchOpen: false } : { dialog: null });
3235
+ };
3236
+ document.addEventListener("keydown", onKeyDown);
3237
+ document.addEventListener("keyup", onKeyUp);
3238
+ return () => {
3239
+ document.removeEventListener("keydown", onKeyDown);
3240
+ document.removeEventListener("keyup", onKeyUp);
3241
+ };
3242
+ }
3243
+
3244
+ // src/pane.tsx
3245
+ function createPane(shell, config) {
3246
+ const stage = div("fnc-stage");
3247
+ const chartHost = div("fnc-chart-host", stage);
3248
+ const overlayHost = div("fnc-overlay", stage);
3249
+ const chart = createChart(chartHost, {
3250
+ ...config.chartOptions,
3251
+ theme: shell.theme(),
3252
+ datafeed: config.datafeed,
3253
+ displayTimezone: config.displayTimezone
3254
+ });
3255
+ chart.setChartType(config.chartType);
2833
3256
  const store = new Store({
2834
- locale,
3257
+ locale: config.chartOptions.locale ?? "ko",
2835
3258
  symbol: null,
2836
- resolution,
3259
+ resolution: config.resolution,
2837
3260
  loading: false,
2838
3261
  layout: chart.layout,
2839
3262
  atRealtime: true,
2840
- fullscreen: false,
2841
3263
  searchOpen: false,
2842
- chartType,
3264
+ chartType: config.chartType,
2843
3265
  drawingTool: null,
2844
3266
  selection: null,
2845
3267
  drawingCount: 0,
@@ -2847,12 +3269,11 @@ function createTradingChart(container, options) {
2847
3269
  magnet: chart.magnetEnabled,
2848
3270
  indicators: chart.getIndicators(),
2849
3271
  dialog: null,
2850
- favoriteIntervals: storage.get("favorite-intervals", [...favoriteIntervals ?? DEFAULT_FAVORITE_INTERVALS]),
2851
- drawbarCollapsed: storage.get("drawbar-collapsed", false)
3272
+ ...shell.sharedState()
2852
3273
  });
2853
3274
  const syncRealtime = () => store.set({ atRealtime: chart.isAtRealtime() });
2854
3275
  const offs = [
2855
- chart.on("symbolChange", (info) => store.set({ symbol: info, resolution: chart.currentResolution ?? resolution })),
3276
+ chart.on("symbolChange", (info) => store.set({ symbol: info, resolution: chart.currentResolution ?? store.get().resolution })),
2856
3277
  chart.on("loadingChange", (loading) => store.set({ loading })),
2857
3278
  chart.on("layoutChange", (layout) => store.set({ layout })),
2858
3279
  chart.on("visibleRangeChange", syncRealtime),
@@ -2862,7 +3283,7 @@ function createTradingChart(container, options) {
2862
3283
  chart.on("drawingsChange", (drawings) => store.set({ drawingCount: drawings.length })),
2863
3284
  chart.on("indicatorsChange", (indicators) => store.set({ indicators })),
2864
3285
  chart.on("magnetChange", (magnet) => store.set({ magnet })),
2865
- chart.on("chartTypeChange", (chartType2) => store.set({ chartType: chartType2 }))
3286
+ chart.on("chartTypeChange", (chartType) => store.set({ chartType }))
2866
3287
  ];
2867
3288
  const load = (sym, res) => {
2868
3289
  store.set({ resolution: res });
@@ -2871,61 +3292,27 @@ function createTradingChart(container, options) {
2871
3292
  store.set({ loading: false });
2872
3293
  });
2873
3294
  };
2874
- const isNativeFullscreen = () => document.fullscreenElement === root;
2875
- const onFullscreenChange = () => store.set({ fullscreen: isNativeFullscreen() || root.classList.contains("fnc-pseudo-fullscreen") });
2876
- const onKeyDown = (e3) => {
2877
- const s3 = store.get();
2878
- if (searchHotkey && (e3.ctrlKey || e3.metaKey) && e3.key.toLowerCase() === "k" && datafeed.searchSymbols && !s3.dialog) {
2879
- e3.preventDefault();
2880
- store.set({ searchOpen: true });
2881
- return;
2882
- }
2883
- if (e3.key === "Escape" && !e3.defaultPrevented && (s3.searchOpen || s3.dialog)) {
2884
- e3.preventDefault();
2885
- store.set(s3.searchOpen ? { searchOpen: false } : { dialog: null });
2886
- return;
2887
- }
2888
- if (e3.key === "Escape" && root.classList.contains("fnc-pseudo-fullscreen") && !s3.searchOpen && !s3.dialog) {
2889
- root.classList.remove("fnc-pseudo-fullscreen");
2890
- onFullscreenChange();
2891
- }
2892
- };
2893
- const onKeyUp = (e3) => {
2894
- const s3 = store.get();
2895
- if (e3.key === "Escape" && (s3.searchOpen || s3.dialog)) store.set(s3.searchOpen ? { searchOpen: false } : { dialog: null });
2896
- };
2897
- document.addEventListener("fullscreenchange", onFullscreenChange);
2898
- document.addEventListener("keydown", onKeyDown);
2899
- document.addEventListener("keyup", onKeyUp);
2900
3295
  const ctx = {
2901
- root,
3296
+ root: shell.root,
2902
3297
  chart,
2903
- datafeed,
3298
+ datafeed: config.datafeed,
2904
3299
  store,
2905
- storage,
2906
- intervalGroups,
2907
- searchCategories,
2908
- displayTimezone,
2909
- showLogo: symbolLogo,
2910
- drawingToolbar,
2911
- attribution,
2912
- slots,
2913
- setDrawbarCollapsed: (collapsed) => {
2914
- store.set({ drawbarCollapsed: collapsed });
2915
- storage.set("drawbar-collapsed", collapsed);
2916
- },
3300
+ storage: shell.storage,
3301
+ intervalGroups: config.intervalGroups,
3302
+ searchCategories: config.searchCategories,
3303
+ displayTimezone: config.displayTimezone,
3304
+ showLogo: config.symbolLogo,
3305
+ drawingToolbar: config.drawingToolbar,
3306
+ attribution: config.attribution,
3307
+ slots: shell.slots,
3308
+ multi: config.multi,
3309
+ setDrawbarCollapsed: shell.setDrawbarCollapsed,
2917
3310
  selectSymbol: (sym) => void load(sym, store.get().resolution),
2918
3311
  selectResolution: (res) => {
2919
3312
  const current = store.get().symbol;
2920
3313
  if (current) void load(current.symbol, res);
2921
3314
  },
2922
- toggleFavoriteInterval: (res) => {
2923
- const current = store.get().favoriteIntervals;
2924
- const next = current.includes(res) ? current.filter((r3) => r3 !== res) : [...current, res];
2925
- store.set({ favoriteIntervals: next });
2926
- storage.set("favorite-intervals", next);
2927
- onFavoriteIntervalsChange?.(next);
2928
- },
3315
+ toggleFavoriteInterval: shell.toggleFavoriteInterval,
2929
3316
  setSearchOpen: (open) => store.set({ searchOpen: open }),
2930
3317
  screenshot: () => {
2931
3318
  const s3 = store.get();
@@ -2938,61 +3325,491 @@ function createTradingChart(container, options) {
2938
3325
  setTimeout(() => URL.revokeObjectURL(a4.href), 1e3);
2939
3326
  });
2940
3327
  },
2941
- toggleFullscreen: () => {
2942
- if (isNativeFullscreen()) {
2943
- void document.exitFullscreen();
2944
- } else if (root.classList.contains("fnc-pseudo-fullscreen")) {
2945
- root.classList.remove("fnc-pseudo-fullscreen");
2946
- onFullscreenChange();
2947
- } else if (root.requestFullscreen && document.fullscreenEnabled) {
2948
- root.requestFullscreen().catch(() => {
2949
- root.classList.add("fnc-pseudo-fullscreen");
2950
- onFullscreenChange();
2951
- });
2952
- } else {
2953
- root.classList.add("fnc-pseudo-fullscreen");
2954
- onFullscreenChange();
2955
- }
2956
- }
3328
+ toggleFullscreen: shell.toggleFullscreen
2957
3329
  };
2958
- Q(/* @__PURE__ */ a2(Toolbar, { ctx }), toolbarHost);
2959
3330
  Q(/* @__PURE__ */ a2(Overlay, { ctx }), overlayHost);
2960
- if (drawbarHost) Q(/* @__PURE__ */ a2(DrawingToolbar, { ctx }), drawbarHost);
2961
- void load(symbol, resolution);
2962
3331
  return {
3332
+ stage,
2963
3333
  chart,
2964
- slots,
2965
- setSymbol: (sym, res) => load(sym, res ?? store.get().resolution),
3334
+ store,
3335
+ ctx,
3336
+ canSearch: typeof config.datafeed.searchSymbols === "function",
3337
+ load,
3338
+ destroy: () => {
3339
+ offs.forEach((off) => off());
3340
+ Q(null, overlayHost);
3341
+ chart.destroy();
3342
+ stage.remove();
3343
+ }
3344
+ };
3345
+ }
3346
+
3347
+ // src/trading-chart.tsx
3348
+ var DEFAULT_INTERVAL_GROUPS = [
3349
+ { id: "minutes", intervals: ["1", "5", "15", "30"] },
3350
+ { id: "hours", intervals: ["60", "240"] },
3351
+ { id: "days", intervals: ["1D", "1W", "1M", "3M", "6M", "12M"] }
3352
+ ];
3353
+ var DEFAULT_FAVORITE_INTERVALS = ["1", "5", "15", "60", "240", "1D"];
3354
+ var stockLike = (i3) => i3.type === "stock" || i3.type === "etf" || i3.type === "index";
3355
+ var DEFAULT_SEARCH_CATEGORIES = [
3356
+ { id: "all", label: { ko: "\uC804\uCCB4", en: "All" }, match: () => true },
3357
+ { id: "kr-stock", label: { ko: "\uAD6D\uB0B4\uC8FC\uC2DD", en: "KR stocks" }, match: (i3) => stockLike(i3) && i3.country === "KR" },
3358
+ { id: "global-stock", label: { ko: "\uD574\uC678\uC8FC\uC2DD", en: "Global stocks" }, match: (i3) => stockLike(i3) && i3.country !== "KR" },
3359
+ { id: "crypto", label: { ko: "\uCF54\uC778", en: "Crypto" }, match: (i3) => i3.type === "crypto" },
3360
+ { id: "futures", label: { ko: "\uC120\uBB3C", en: "Futures" }, match: (i3) => i3.type === "futures" }
3361
+ ];
3362
+ function defaultDisplayTimezone(locale, explicit) {
3363
+ return explicit ?? (locale === "ko" ? "Asia/Seoul" : Intl.DateTimeFormat().resolvedOptions().timeZone);
3364
+ }
3365
+ function createTradingChart(container, options) {
3366
+ const {
3367
+ datafeed,
3368
+ symbol,
3369
+ resolution = "1D",
3370
+ intervalGroups = DEFAULT_INTERVAL_GROUPS,
3371
+ favoriteIntervals,
3372
+ onFavoriteIntervalsChange,
3373
+ searchCategories = DEFAULT_SEARCH_CATEGORIES,
3374
+ storageKey = "funnest-charts",
3375
+ searchHotkey = true,
3376
+ symbolLogo = true,
3377
+ drawingToolbar = true,
3378
+ chartType = "candles",
3379
+ attribution = true,
3380
+ colorScheme = "system",
3381
+ theme,
3382
+ ...chartOptions
3383
+ } = options;
3384
+ const displayTimezone = defaultDisplayTimezone(chartOptions.locale ?? "ko", chartOptions.displayTimezone);
3385
+ const shell = createShell(container, {
3386
+ colorScheme,
3387
+ theme,
3388
+ storageKey,
3389
+ drawingToolbar,
3390
+ favoriteIntervals: favoriteIntervals ?? DEFAULT_FAVORITE_INTERVALS,
3391
+ onFavoriteIntervalsChange
3392
+ });
3393
+ const pane = createPane(shell, {
3394
+ datafeed,
3395
+ chartOptions,
3396
+ displayTimezone,
3397
+ resolution,
3398
+ chartType,
3399
+ intervalGroups,
3400
+ searchCategories,
3401
+ symbolLogo,
3402
+ drawingToolbar,
3403
+ attribution
3404
+ });
3405
+ shell.main.insertBefore(pane.stage, shell.slots.bottom);
3406
+ shell.setPanes(() => [pane]);
3407
+ const unbindKeys = bindWidgetKeys(shell, () => pane, searchHotkey);
3408
+ Q(/* @__PURE__ */ a2(Toolbar, { ctx: pane.ctx }), shell.toolbarHost);
3409
+ if (shell.drawbarHost) Q(/* @__PURE__ */ a2(DrawingToolbar, { ctx: pane.ctx }), shell.drawbarHost);
3410
+ void pane.load(symbol, resolution);
3411
+ return {
3412
+ chart: pane.chart,
3413
+ slots: shell.slots,
3414
+ setSymbol: (sym, res) => pane.load(sym, res ?? pane.store.get().resolution),
2966
3415
  setResolution: (res) => {
2967
- const current = store.get().symbol;
2968
- return current ? load(current.symbol, res) : Promise.resolve();
3416
+ const current = pane.store.get().symbol;
3417
+ return current ? pane.load(current.symbol, res) : Promise.resolve();
2969
3418
  },
2970
- setLocale: (locale2) => {
2971
- chart.setLocale(locale2);
2972
- store.set({ locale: locale2 });
3419
+ setLocale: (locale) => {
3420
+ pane.chart.setLocale(locale);
3421
+ pane.store.set({ locale });
2973
3422
  },
2974
- save: () => chart.save(),
2975
- load: (state) => chart.load(state),
2976
- setTheme: (patch) => {
2977
- themePatch = { ...themePatch, ...patch };
2978
- refreshScheme();
3423
+ save: () => pane.chart.save(),
3424
+ load: (state) => pane.chart.load(state),
3425
+ setTheme: shell.setTheme,
3426
+ setColorScheme: shell.setColorScheme,
3427
+ destroy: () => {
3428
+ unbindKeys();
3429
+ Q(null, shell.toolbarHost);
3430
+ if (shell.drawbarHost) Q(null, shell.drawbarHost);
3431
+ pane.destroy();
3432
+ shell.destroy();
3433
+ }
3434
+ };
3435
+ }
3436
+
3437
+ // src/multi-chart.tsx
3438
+ import { parseChartState } from "@funnest/charts";
3439
+ var DEFAULT_SYNC = { crosshair: true, time: false, symbol: false, interval: false };
3440
+ function PaneTab({ pane, index, active, onSelect }) {
3441
+ const s3 = useStore(pane.store);
3442
+ const t3 = STRINGS[s3.locale];
3443
+ return /* @__PURE__ */ a2("button", { class: "fnc-pane-tab", role: "tab", "aria-selected": active, onClick: onSelect, children: [
3444
+ /* @__PURE__ */ a2("span", { class: "fnc-pane-tab-num", children: index + 1 }),
3445
+ s3.symbol?.name ?? `${t3.pane} ${index + 1}`,
3446
+ /* @__PURE__ */ a2("small", { children: resolutionLabel(s3.resolution, s3.locale) })
3447
+ ] });
3448
+ }
3449
+ function PaneTabs({ multi, panes }) {
3450
+ const m3 = useStore(multi.store);
3451
+ const visible = panes().slice(0, paneCount(LAYOUTS[m3.layout]));
3452
+ if (!m3.narrow || visible.length < 2) return null;
3453
+ return /* @__PURE__ */ a2("div", { class: "fnc-pane-tabs", role: "tablist", children: visible.map((p3, i3) => /* @__PURE__ */ a2(PaneTab, { pane: p3, index: i3, active: i3 === m3.active, onSelect: () => multi.activate(i3) })) });
3454
+ }
3455
+ function createMultiChart(container, options) {
3456
+ const {
3457
+ datafeed,
3458
+ panes: paneSpecs,
3459
+ layout: initialLayout = "1",
3460
+ sync: initialSync,
3461
+ narrowWidth = 640,
3462
+ resolution = "1D",
3463
+ intervalGroups = DEFAULT_INTERVAL_GROUPS,
3464
+ favoriteIntervals,
3465
+ onFavoriteIntervalsChange,
3466
+ searchCategories = DEFAULT_SEARCH_CATEGORIES,
3467
+ storageKey = "funnest-charts",
3468
+ searchHotkey = true,
3469
+ symbolLogo = true,
3470
+ drawingToolbar = true,
3471
+ chartType = "candles",
3472
+ attribution = true,
3473
+ colorScheme = "system",
3474
+ theme,
3475
+ ...chartOptions
3476
+ } = options;
3477
+ if (paneSpecs.length === 0) throw new Error("funnest-charts: createMultiChart needs at least one pane");
3478
+ const displayTimezone = defaultDisplayTimezone(chartOptions.locale ?? "ko", chartOptions.displayTimezone);
3479
+ const shell = createShell(container, {
3480
+ colorScheme,
3481
+ theme,
3482
+ storageKey,
3483
+ drawingToolbar,
3484
+ favoriteIntervals: favoriteIntervals ?? DEFAULT_FAVORITE_INTERVALS,
3485
+ onFavoriteIntervalsChange
3486
+ });
3487
+ shell.root.classList.add("fnc-multi");
3488
+ const tabsHost = div("fnc-pane-tabs-host");
3489
+ shell.root.insertBefore(tabsHost, shell.body);
3490
+ const grid = div("fnc-grid");
3491
+ shell.main.insertBefore(grid, shell.slots.bottom);
3492
+ const parking = div("fnc-parking", shell.root);
3493
+ const listeners = /* @__PURE__ */ new Map();
3494
+ const emit = (event, payload) => {
3495
+ for (const l3 of listeners.get(event) ?? []) l3(payload);
3496
+ };
3497
+ let stateQueued = false;
3498
+ const queueState = () => {
3499
+ if (stateQueued) return;
3500
+ stateQueued = true;
3501
+ queueMicrotask(() => {
3502
+ stateQueued = false;
3503
+ emit("stateChange", void 0);
3504
+ });
3505
+ };
3506
+ const store = new Store({
3507
+ layout: isLayoutId(initialLayout) ? initialLayout : "1",
3508
+ sync: { ...DEFAULT_SYNC, ...initialSync },
3509
+ active: 0,
3510
+ maximized: null,
3511
+ narrow: false
3512
+ });
3513
+ let sizes = {};
3514
+ const panes = [];
3515
+ const cells = [];
3516
+ let driver = -1;
3517
+ const visibleCount = () => Math.min(paneCount(LAYOUTS[store.get().layout]), MAX_PANES);
3518
+ const visible = () => panes.slice(0, visibleCount());
3519
+ const controls = {
3520
+ store,
3521
+ setLayout: (layout) => setLayout(layout),
3522
+ setSync: (patch) => setSync(patch),
3523
+ activate: (index) => activate(index),
3524
+ toggleMaximize: (index) => toggleMaximize(index)
3525
+ };
3526
+ function makePane(index, spec) {
3527
+ const pane = createPane(shell, {
3528
+ datafeed,
3529
+ chartOptions,
3530
+ displayTimezone,
3531
+ resolution: spec.resolution ?? resolution,
3532
+ chartType,
3533
+ intervalGroups,
3534
+ searchCategories,
3535
+ symbolLogo,
3536
+ drawingToolbar,
3537
+ // One badge for the whole widget, not one per pane.
3538
+ attribution: attribution && index === 0,
3539
+ multi: controls
3540
+ });
3541
+ const cell = div("fnc-pane");
3542
+ cell.appendChild(pane.stage);
3543
+ const touch = () => {
3544
+ driver = panes.indexOf(pane);
3545
+ if (driver >= 0 && driver !== store.get().active) activate(driver);
3546
+ };
3547
+ cell.addEventListener("pointerdown", touch, true);
3548
+ cell.addEventListener("wheel", () => driver = panes.indexOf(pane), { capture: true, passive: true });
3549
+ const c3 = pane.chart;
3550
+ c3.on("crosshairMove", (info) => {
3551
+ if (!store.get().sync.crosshair || info?.source === "sync") return;
3552
+ for (const other of visible()) if (other !== pane) other.chart.setSyncedCrosshair(info ? info.time : null);
3553
+ });
3554
+ c3.on("visibleRangeChange", () => {
3555
+ if (!store.get().sync.time || panes[driver] !== pane) return;
3556
+ broadcastTime(pane);
3557
+ });
3558
+ c3.on("symbolChange", (info) => {
3559
+ queueState();
3560
+ if (panes[store.get().active] !== pane) return;
3561
+ const res = c3.currentResolution;
3562
+ const { sync } = store.get();
3563
+ if (!sync.symbol && !sync.interval) return;
3564
+ for (const other of visible()) {
3565
+ if (other === pane) continue;
3566
+ const sym = sync.symbol ? info.symbol : other.chart.symbolInfo?.symbol;
3567
+ const r3 = sync.interval && res ? res : other.chart.currentResolution ?? other.store.get().resolution;
3568
+ if (sym && (sym !== other.chart.symbolInfo?.symbol || r3 !== other.chart.currentResolution)) void other.load(sym, r3);
3569
+ }
3570
+ });
3571
+ c3.on("stateChange", queueState);
3572
+ panes[index] = pane;
3573
+ cells[index] = cell;
3574
+ void pane.load(spec.symbol, spec.resolution ?? resolution);
3575
+ return pane;
3576
+ }
3577
+ function broadcastTime(from) {
3578
+ const range = from.chart.getVisibleTimeRange();
3579
+ if (!range) return;
3580
+ const res = from.chart.currentResolution;
3581
+ for (const other of visible()) {
3582
+ if (other !== from && other.chart.currentResolution === res) other.chart.setVisibleTimeRange(range);
3583
+ }
3584
+ }
3585
+ function ensurePanes(count) {
3586
+ for (let i3 = panes.length; i3 < count; i3++) {
3587
+ const src = panes[store.get().active] ?? panes[0];
3588
+ const spec = paneSpecs[i3] ?? {
3589
+ symbol: src?.chart.symbolInfo?.symbol ?? paneSpecs[0].symbol,
3590
+ resolution: src?.chart.currentResolution ?? src?.store.get().resolution ?? resolution
3591
+ };
3592
+ makePane(i3, spec);
3593
+ }
3594
+ }
3595
+ function buildNode(node, path, layout) {
3596
+ if (typeof node === "number") return cells[node];
3597
+ const box = div(`fnc-split fnc-split-${node.dir}`);
3598
+ const key = `${layout}:${path}`;
3599
+ const weights = sizesFor(sizes, key, node.children.length);
3600
+ const children = node.children.map((child, i3) => {
3601
+ const el = buildNode(child, path ? `${path}.${i3}` : String(i3), layout);
3602
+ el.style.flex = `${weights[i3]} 1 0px`;
3603
+ return el;
3604
+ });
3605
+ children.forEach((el, i3) => {
3606
+ box.appendChild(el);
3607
+ if (i3 < children.length - 1) box.appendChild(makeSplitter(box, node.dir, key, i3, children));
3608
+ });
3609
+ return box;
3610
+ }
3611
+ function makeSplitter(box, dir, key, i3, children) {
3612
+ const bar = div(`fnc-splitter fnc-splitter-${dir}`);
3613
+ bar.setAttribute("role", "separator");
3614
+ bar.setAttribute("aria-orientation", dir === "row" ? "vertical" : "horizontal");
3615
+ let last = null;
3616
+ const apply = (next) => {
3617
+ sizes = { ...sizes, [key]: next };
3618
+ next.forEach((w3, j2) => children[j2].style.flex = `${w3} 1 0px`);
3619
+ queueState();
3620
+ };
3621
+ bar.addEventListener("pointerdown", (e3) => {
3622
+ bar.setPointerCapture(e3.pointerId);
3623
+ last = dir === "row" ? e3.clientX : e3.clientY;
3624
+ shell.root.classList.add(dir === "row" ? "fnc-resizing-x" : "fnc-resizing-y");
3625
+ });
3626
+ bar.addEventListener("pointermove", (e3) => {
3627
+ if (last === null) return;
3628
+ const pos = dir === "row" ? e3.clientX : e3.clientY;
3629
+ const length = dir === "row" ? box.clientWidth : box.clientHeight;
3630
+ if (length <= 0) return;
3631
+ apply(resizePair(sizesFor(sizes, key, children.length), i3, (pos - last) / length));
3632
+ last = pos;
3633
+ });
3634
+ const end = () => {
3635
+ last = null;
3636
+ shell.root.classList.remove("fnc-resizing-x", "fnc-resizing-y");
3637
+ };
3638
+ bar.addEventListener("pointerup", end);
3639
+ bar.addEventListener("pointercancel", end);
3640
+ bar.addEventListener("dblclick", () => apply(Array(children.length).fill(1)));
3641
+ return bar;
3642
+ }
3643
+ function renderGrid() {
3644
+ const s3 = store.get();
3645
+ const count = visibleCount();
3646
+ for (let i3 = 0; i3 < panes.length; i3++) {
3647
+ if (i3 >= count) parking.appendChild(cells[i3]);
3648
+ cells[i3].classList.toggle("fnc-pane-active", i3 === s3.active && count > 1);
3649
+ }
3650
+ const single = s3.maximized ?? (s3.narrow ? s3.active : null);
3651
+ const top = single !== null && single < count ? cells[single] : buildNode(LAYOUTS[s3.layout], "", s3.layout);
3652
+ top.style.flex = "1 1 0px";
3653
+ grid.replaceChildren(top);
3654
+ shell.root.classList.toggle("fnc-split-on", count > 1 && single === null);
3655
+ }
3656
+ function bindToolbar() {
3657
+ const pane = panes[store.get().active];
3658
+ Q(/* @__PURE__ */ a2(Toolbar, { ctx: pane.ctx }, store.get().active), shell.toolbarHost);
3659
+ if (shell.drawbarHost) Q(/* @__PURE__ */ a2(DrawingToolbar, { ctx: pane.ctx }, store.get().active), shell.drawbarHost);
3660
+ }
3661
+ function activate(index) {
3662
+ const s3 = store.get();
3663
+ if (index < 0 || index >= visibleCount() || index === s3.active) return;
3664
+ panes[s3.active]?.chart.setDrawingTool(null);
3665
+ store.set({ active: index, maximized: s3.maximized === null ? null : index });
3666
+ bindToolbar();
3667
+ renderGrid();
3668
+ emit("activeChange", { index, chart: panes[index].chart });
3669
+ queueState();
3670
+ }
3671
+ function setLayout(layout) {
3672
+ if (!isLayoutId(layout)) return;
3673
+ const count = paneCount(LAYOUTS[layout]);
3674
+ ensurePanes(count);
3675
+ const s3 = store.get();
3676
+ for (const p3 of panes.slice(count)) p3.chart.setSyncedCrosshair(null);
3677
+ store.set({ layout, maximized: null, active: Math.min(s3.active, count - 1) });
3678
+ if (s3.active !== store.get().active) bindToolbar();
3679
+ renderGrid();
3680
+ emit("layoutChange", { layout, panes: count });
3681
+ queueState();
3682
+ }
3683
+ function setSync(patch) {
3684
+ const before = store.get().sync;
3685
+ const sync = { ...before, ...patch };
3686
+ store.set({ sync });
3687
+ const active = panes[store.get().active];
3688
+ if (!sync.crosshair) for (const p3 of panes) p3.chart.setSyncedCrosshair(null);
3689
+ if (sync.time && !before.time) broadcastTime(active);
3690
+ if (sync.symbol && !before.symbol || sync.interval && !before.interval) {
3691
+ const info = active.chart.symbolInfo;
3692
+ const res = active.chart.currentResolution;
3693
+ for (const other of visible()) {
3694
+ if (other === active || !info) continue;
3695
+ const sym = sync.symbol ? info.symbol : other.chart.symbolInfo?.symbol ?? info.symbol;
3696
+ const r3 = sync.interval && res ? res : other.chart.currentResolution ?? other.store.get().resolution;
3697
+ if (sym !== other.chart.symbolInfo?.symbol || r3 !== other.chart.currentResolution) void other.load(sym, r3);
3698
+ }
3699
+ }
3700
+ queueState();
3701
+ }
3702
+ function toggleMaximize(index) {
3703
+ const s3 = store.get();
3704
+ const target = index ?? s3.active;
3705
+ if (s3.maximized !== null) store.set({ maximized: null });
3706
+ else if (visibleCount() > 1) {
3707
+ if (target !== s3.active) activate(target);
3708
+ store.set({ maximized: target });
3709
+ }
3710
+ renderGrid();
3711
+ }
3712
+ makePane(0, paneSpecs[0]);
3713
+ ensurePanes(Math.max(visibleCount(), Math.min(paneSpecs.length, MAX_PANES)));
3714
+ shell.setPanes(() => panes);
3715
+ bindToolbar();
3716
+ Q(/* @__PURE__ */ a2(PaneTabs, { multi: controls, panes: () => panes }), tabsHost);
3717
+ renderGrid();
3718
+ const resize = new ResizeObserver(() => {
3719
+ const narrow = shell.root.clientWidth > 0 && shell.root.clientWidth < narrowWidth;
3720
+ if (narrow === store.get().narrow) return;
3721
+ store.set({ narrow });
3722
+ renderGrid();
3723
+ });
3724
+ resize.observe(shell.root);
3725
+ const unbindKeys = bindWidgetKeys(
3726
+ shell,
3727
+ () => panes[store.get().active] ?? null,
3728
+ searchHotkey,
3729
+ (e3) => {
3730
+ if (!e3.altKey || e3.ctrlKey || e3.metaKey) return false;
3731
+ if (e3.key === "Enter") {
3732
+ toggleMaximize();
3733
+ return true;
3734
+ }
3735
+ const digit = /^Digit([1-8])$/.exec(e3.code)?.[1];
3736
+ if (!digit) return false;
3737
+ activate(Number(digit) - 1);
3738
+ return true;
3739
+ }
3740
+ );
3741
+ const save = () => {
3742
+ const s3 = store.get();
3743
+ return { version: 1, layout: s3.layout, sync: { ...s3.sync }, active: s3.active, sizes: { ...sizes }, panes: panes.map((p3) => p3.chart.save()) };
3744
+ };
3745
+ return {
3746
+ slots: shell.slots,
3747
+ get charts() {
3748
+ return visible().map((p3) => p3.chart);
2979
3749
  },
2980
- setColorScheme: (next) => {
2981
- scheme = next;
2982
- refreshScheme();
3750
+ get activeChart() {
3751
+ return panes[store.get().active].chart;
3752
+ },
3753
+ get activeIndex() {
3754
+ return store.get().active;
3755
+ },
3756
+ get layout() {
3757
+ return store.get().layout;
3758
+ },
3759
+ get sync() {
3760
+ return { ...store.get().sync };
3761
+ },
3762
+ setLayout,
3763
+ setSync,
3764
+ activate,
3765
+ toggleMaximize,
3766
+ setSymbol: (sym, res) => {
3767
+ const pane = panes[store.get().active];
3768
+ return pane.load(sym, res ?? pane.store.get().resolution);
3769
+ },
3770
+ setResolution: (res) => {
3771
+ const pane = panes[store.get().active];
3772
+ const current = pane.store.get().symbol;
3773
+ return current ? pane.load(current.symbol, res) : Promise.resolve();
3774
+ },
3775
+ setLocale: (locale) => {
3776
+ for (const p3 of panes) {
3777
+ p3.chart.setLocale(locale);
3778
+ p3.store.set({ locale });
3779
+ }
3780
+ },
3781
+ setTheme: shell.setTheme,
3782
+ setColorScheme: shell.setColorScheme,
3783
+ save,
3784
+ load: async (input) => {
3785
+ const raw = input;
3786
+ if (!raw || typeof raw !== "object" || raw.version !== 1 || !Array.isArray(raw.panes) || raw.panes.length === 0) {
3787
+ throw new Error("funnest-charts: unsupported multi-chart state (expected version 1)");
3788
+ }
3789
+ const states = raw.panes.slice(0, MAX_PANES).map((p3) => parseChartState(p3));
3790
+ sizes = raw.sizes && typeof raw.sizes === "object" ? { ...raw.sizes } : {};
3791
+ const layout = isLayoutId(raw.layout) ? raw.layout : "1";
3792
+ ensurePanes(Math.max(states.length, paneCount(LAYOUTS[layout])));
3793
+ store.set({ sync: { ...DEFAULT_SYNC, ...raw.sync } });
3794
+ setLayout(layout);
3795
+ activate(Math.min(Math.max(0, Number(raw.active) || 0), visibleCount() - 1));
3796
+ await Promise.all(states.map((state, i3) => panes[i3].chart.load(state)));
3797
+ },
3798
+ on: (event, listener) => {
3799
+ let set = listeners.get(event);
3800
+ if (!set) listeners.set(event, set = /* @__PURE__ */ new Set());
3801
+ set.add(listener);
3802
+ return () => set.delete(listener);
2983
3803
  },
2984
3804
  destroy: () => {
2985
- offs.forEach((off) => off());
2986
- document.removeEventListener("fullscreenchange", onFullscreenChange);
2987
- document.removeEventListener("keydown", onKeyDown);
2988
- document.removeEventListener("keyup", onKeyUp);
2989
- darkQuery?.removeEventListener?.("change", onSchemeChange);
2990
- if (isNativeFullscreen()) void document.exitFullscreen();
2991
- Q(null, toolbarHost);
2992
- Q(null, overlayHost);
2993
- if (drawbarHost) Q(null, drawbarHost);
2994
- chart.destroy();
2995
- root.remove();
3805
+ unbindKeys();
3806
+ resize.disconnect();
3807
+ Q(null, shell.toolbarHost);
3808
+ if (shell.drawbarHost) Q(null, shell.drawbarHost);
3809
+ Q(null, tabsHost);
3810
+ for (const p3 of panes) p3.destroy();
3811
+ listeners.clear();
3812
+ shell.destroy();
2996
3813
  }
2997
3814
  };
2998
3815
  }
@@ -3000,6 +3817,11 @@ export {
3000
3817
  DEFAULT_FAVORITE_INTERVALS,
3001
3818
  DEFAULT_INTERVAL_GROUPS,
3002
3819
  DEFAULT_SEARCH_CATEGORIES,
3820
+ LAYOUTS,
3821
+ LAYOUT_GROUPS,
3822
+ MAX_PANES,
3823
+ createMultiChart,
3003
3824
  createTradingChart,
3825
+ paneCount,
3004
3826
  resolutionLabel
3005
3827
  };