@tienne/gestalt 0.91.0 → 0.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +2 -0
  2. package/dist/package.json +1 -1
  3. package/dist/plugin/skills/architecture/SKILL.md +23 -5
  4. package/dist/schemas/architecture-ir.schema.json +54 -1
  5. package/dist/src/architecture/flow-layout.d.ts +16 -1
  6. package/dist/src/architecture/flow-layout.d.ts.map +1 -1
  7. package/dist/src/architecture/flow-layout.js +108 -28
  8. package/dist/src/architecture/flow-layout.js.map +1 -1
  9. package/dist/src/architecture/html-client.d.ts +5 -0
  10. package/dist/src/architecture/html-client.d.ts.map +1 -1
  11. package/dist/src/architecture/html-client.js +217 -15
  12. package/dist/src/architecture/html-client.js.map +1 -1
  13. package/dist/src/architecture/html-renderer.d.ts.map +1 -1
  14. package/dist/src/architecture/html-renderer.js +279 -25
  15. package/dist/src/architecture/html-renderer.js.map +1 -1
  16. package/dist/src/architecture/html-theme.d.ts +7 -0
  17. package/dist/src/architecture/html-theme.d.ts.map +1 -1
  18. package/dist/src/architecture/html-theme.js +96 -0
  19. package/dist/src/architecture/html-theme.js.map +1 -1
  20. package/dist/src/architecture/ir-schema.d.ts +124 -27
  21. package/dist/src/architecture/ir-schema.d.ts.map +1 -1
  22. package/dist/src/architecture/ir-schema.js +24 -2
  23. package/dist/src/architecture/ir-schema.js.map +1 -1
  24. package/dist/src/architecture/merge.d.ts.map +1 -1
  25. package/dist/src/architecture/merge.js +9 -0
  26. package/dist/src/architecture/merge.js.map +1 -1
  27. package/dist/src/architecture/sequence-views.d.ts +155 -0
  28. package/dist/src/architecture/sequence-views.d.ts.map +1 -0
  29. package/dist/src/architecture/sequence-views.js +407 -0
  30. package/dist/src/architecture/sequence-views.js.map +1 -0
  31. package/dist/src/architecture/types.d.ts +23 -1
  32. package/dist/src/architecture/types.d.ts.map +1 -1
  33. package/dist/src/architecture/types.js +2 -0
  34. package/dist/src/architecture/types.js.map +1 -1
  35. package/dist/src/architecture/validator.d.ts +2 -1
  36. package/dist/src/architecture/validator.d.ts.map +1 -1
  37. package/dist/src/architecture/validator.js +119 -0
  38. package/dist/src/architecture/validator.js.map +1 -1
  39. package/dist/src/mcp/schemas.d.ts +6 -6
  40. package/package.json +1 -1
  41. package/plugin/.codex-plugin/plugin.json +1 -1
  42. package/plugin/.mcp.json +1 -1
  43. package/plugin/mcp.json +1 -1
  44. package/plugin/skills/architecture/SKILL.md +23 -5
  45. package/schemas/architecture-ir.schema.json +54 -1
@@ -1,7 +1,7 @@
1
1
  import { BAND_LINE_X, BAND_LINE_Y, BAND_TITLE_HALF, BAND_TITLE_X, CANVAS_PAD_TOP, CANVAS_PAD_X, LANE_INSET_Y, routeCanvas, } from './canvas-geometry.js';
2
2
  import { flowCountByService, ROOT_LEVEL_ID } from './drilldown.js';
3
3
  import { renderClientScript, THEME_BOOT_SCRIPT } from './html-client.js';
4
- import { datastoreEngine, iconUse, PRODUCT_COLORS, renderCss, renderIconSprite, VIEW_CSS, DOC_CSS, } from './html-theme.js';
4
+ import { datastoreEngine, iconUse, PRODUCT_COLORS, renderCss, renderIconSprite, VIEW_CSS, DOC_CSS, FLOW_BRANCH_CSS, SEQ_VIEW_CSS, } from './html-theme.js';
5
5
  import { EDGE_KIND_ABOUT, LANE_ABOUT, LANE_TITLES, NODE_KIND_ABOUT, STAGE_LANE_ABOUT, flowBadgeText, MICRO_HOST_SHORT, NODE_KIND_SHORT, PLATFORM_CHIP_TEXT, PLATFORM_NAME, WEB_HOSTING_CHIP_TEXT, WEB_HOSTING_NAME, platformChipText, platformName, } from './kind-text.js';
6
6
  import { compareEnvironment, FLAT_BACKWARD_EDGE_KINDS, textUnits, } from './layout.js';
7
7
  import { indexMicroApps } from './micro-app.js';
@@ -10,6 +10,7 @@ import { KNOWLEDGE_COVERAGE_KINDS, KNOWLEDGE_DOC_KINDS } from './packs/knowledge
10
10
  import { computeCompareLayout } from './compare-layout.js';
11
11
  import { computeDataflowLayout } from './dataflow-layout.js';
12
12
  import { computeSequenceLayout } from './sequence-layout.js';
13
+ import { clipText, computePhaseBoard, computeWalkLayout, phaseMetaText, resolveSequencePhases, sequenceCallDepths, } from './sequence-views.js';
13
14
  import { computeServiceFacts } from './service-facts.js';
14
15
  import { chipTextOverride, displayKindOf, hasCardDescription } from './types.js';
15
16
  import { maskSharedText, redactForSharing } from './validator.js';
@@ -57,6 +58,9 @@ const FLOW_TEXT = {
57
58
  side: '옆 흐름',
58
59
  back: '앞 단계로 되돌아감',
59
60
  end: '흐름 끝',
61
+ decision: '갈림길',
62
+ trigger: '누른 것',
63
+ condition: '조건',
60
64
  person: '사람',
61
65
  system: '시스템',
62
66
  };
@@ -567,13 +571,19 @@ function renderFlowSection(level, flow, questionCount, drawableNodes) {
567
571
  `y1="${round2(laneTop + FLOW_PAD)}" y2="${round2(laneBottom + FLOW_PAD)}"/>`)
568
572
  .join('');
569
573
  const stageHeads = layout.stages
570
- .map((st) => `<div class="fstage${st.side ? ' side' : named(st.label)}" style="left:${round2(st.x + FLOW_PAD + 8)}px;top:${FLOW_PAD}px;width:${round2(st.width - 16)}px" ` +
574
+ .map((st) => `<div class="fstage${st.side ? ' side' : named(st.label)}" style="left:${round2(st.x + FLOW_PAD + 8)}px;top:${FLOW_PAD + layout.stageTop}px;width:${round2(st.width - 16)}px" ` +
571
575
  `title="${escapeHtml(st.side ? st.label : stateText(st.label))}"><span>${escapeHtml(st.side ? st.label : stateText(st.label))}</span></div>`)
572
576
  .join('');
573
- const heads = layout.lanes
574
- .map((l) => `<div class="flane-title a-${l.actor.kind}" style="left:${FLOW_PAD + 12}px;top:${round2(l.y + FLOW_PAD + 12)}px">` +
575
- `${iconUse(l.actor.kind === 'person' ? 'u-person' : l.actor.kind === 'agent' ? 'i-agent' : 'u-system')}<span>${escapeHtml(l.actor.label)}</span></div>`)
576
- .join('');
577
+ const actorIcon = (kind) => iconUse(kind === 'person' ? 'u-person' : kind === 'agent' ? 'i-agent' : 'u-system');
578
+ // 행위자가 한 명이면 머리 칸이 없다. 누가 하는 흐름인지는 맨 위 띠의 칩 하나로 알린다
579
+ const solo = layout.headWidth === 0 ? layout.lanes[0] : undefined;
580
+ const heads = solo !== undefined
581
+ ? `<div class="flane-actor a-${solo.actor.kind}" style="left:${FLOW_PAD + 4}px;top:${FLOW_PAD}px">` +
582
+ `${actorIcon(solo.actor.kind)}<span>${escapeHtml(solo.actor.label)}</span></div>`
583
+ : layout.lanes
584
+ .map((l) => `<div class="flane-title a-${l.actor.kind}" style="left:${FLOW_PAD + 12}px;top:${round2(l.y + FLOW_PAD + 12)}px">` +
585
+ `${actorIcon(l.actor.kind)}<span>${escapeHtml(l.actor.label)}</span></div>`)
586
+ .join('');
577
587
  const labelOf = (id) => stepById.get(id)?.label ?? id;
578
588
  const links = layout.transitions
579
589
  .map((r) => {
@@ -585,17 +595,32 @@ function renderFlowSection(level, flow, questionCount, drawableNodes) {
585
595
  const who = actorNames.join(', ');
586
596
  // 선 위에는 괄호 앞 이름만 싣는다. "고객 (앱, QR, 알림톡 웹)"을 다 쓰면 선 글자가 카드 폭을 넘는다
587
597
  const whoShort = actorNames.map((n) => n.replace(/\s*\([^)]*\)\s*$/, '')).join(', ');
598
+ // 누른 것이나 조건이 있으면 선에는 그 둘을 찍고 label은 서랍의 메모로만 보인다
599
+ const rich = t.trigger !== undefined || t.condition !== undefined;
600
+ const richParts = [
601
+ ...(t.condition !== undefined ? [`${FLOW_TEXT.condition} ${t.condition}`] : []),
602
+ ...(t.trigger !== undefined ? [`${FLOW_TEXT.trigger} ${t.trigger}`] : []),
603
+ ];
588
604
  const name = `${FLOW_TEXT[r.path]}${r.back ? `, ${FLOW_TEXT.back}` : ''}: ${labelOf(r.from)} → ${labelOf(r.to)}` +
589
- (t.label !== undefined ? ` (${t.label})` : '') +
605
+ (rich ? ` (${richParts.join(', ')})` : t.label !== undefined ? ` (${t.label})` : '') +
590
606
  (who !== '' ? `, 누가 ${who}` : '');
607
+ const richText = (t.condition !== undefined
608
+ ? `<tspan class="t-cond">${escapeHtml(t.condition)}${t.trigger !== undefined ? ' ' : ''}</tspan>`
609
+ : '') + (t.trigger !== undefined ? escapeHtml(t.trigger) : '');
591
610
  const text = r.labelAt
592
- ? `<text class="t-label" x="${round2(r.labelAt.x + FLOW_PAD)}" y="${round2(r.labelAt.y + FLOW_PAD - 5)}">` +
593
- (r.back ? '<tspan class="t-back">↩ </tspan>' : '') +
594
- (t.label !== undefined ? escapeHtml(t.label) : '') +
595
- (whoShort !== ''
596
- ? `<tspan class="t-who">${t.label !== undefined ? ' · ' : ''}${escapeHtml(whoShort)}</tspan>`
597
- : '') +
598
- `</text>`
611
+ ? rich
612
+ ? `<text class="t-label${r.labelAnchor === 'start' ? ' at-start' : ''}" x="${round2(r.labelAt.x + FLOW_PAD)}" y="${round2(r.labelAt.y + FLOW_PAD - 5)}">` +
613
+ (r.back ? '<tspan class="t-back">↩ </tspan>' : '') +
614
+ richText +
615
+ (whoShort !== '' ? `<tspan class="t-who"> · ${escapeHtml(whoShort)}</tspan>` : '') +
616
+ `</text>`
617
+ : `<text class="t-label${r.labelAnchor === 'start' ? ' at-start' : ''}" x="${round2(r.labelAt.x + FLOW_PAD)}" y="${round2(r.labelAt.y + FLOW_PAD - 5)}">` +
618
+ (r.back ? '<tspan class="t-back">↩ </tspan>' : '') +
619
+ (t.label !== undefined ? escapeHtml(t.label) : '') +
620
+ (whoShort !== ''
621
+ ? `<tspan class="t-who">${t.label !== undefined ? ' · ' : ''}${escapeHtml(whoShort)}</tspan>`
622
+ : '') +
623
+ `</text>`
599
624
  : '';
600
625
  return (`<g class="link flow-t p-${r.path}${r.back ? ' back' : ''}" data-from="${escapeHtml(r.from)}" data-to="${escapeHtml(r.to)}" ` +
601
626
  `data-transition-id="${escapeHtml(r.id)}" tabindex="0" role="button" aria-label="${escapeHtml(name)}">` +
@@ -610,16 +635,23 @@ function renderFlowSection(level, flow, questionCount, drawableNodes) {
610
635
  const refs = (st.refs ?? []).filter((id) => drawableNodes.has(id));
611
636
  const qs = questionCount.get(st.id) ?? 0;
612
637
  const dashed = st.evidence.every((ev) => ev.type !== 'code' && ev.type !== 'spec');
638
+ const decision = b.shape === 'diamond';
613
639
  const cls = ['node', 'flow-step', `p-${b.path}`];
640
+ if (decision)
641
+ cls.push('decision');
614
642
  if (dashed)
615
643
  cls.push('doc-only');
616
- const aria = `${st.label}, ${FLOW_TEXT.step}, ${actor?.label ?? st.actor}` +
644
+ const aria = `${st.label}, ${decision ? FLOW_TEXT.decision : FLOW_TEXT.step}, ${actor?.label ?? st.actor}` +
617
645
  (st.state !== undefined ? `, 상태 ${stateText(st.state)}` : '') +
618
646
  (st.terminal ? `, ${FLOW_TEXT.end}` : '') +
619
647
  (dashed ? ', 문서로만 확인' : '');
620
648
  return (`<div class="${cls.join(' ')}" data-node-id="${escapeHtml(st.id)}" role="button" tabindex="0" ` +
621
649
  `aria-label="${escapeHtml(aria)}" title="${escapeHtml(st.description ?? st.label)}" ` +
622
650
  `style="left:${round2(b.x + FLOW_PAD)}px;top:${round2(b.y + FLOW_PAD)}px;width:${b.width}px;height:${b.height}px">` +
651
+ (decision
652
+ ? `<svg class="dshape" viewBox="0 0 ${b.width} ${b.height}" preserveAspectRatio="none" aria-hidden="true">` +
653
+ `<polygon points="${b.width / 2},1 ${b.width - 1},${b.height / 2} ${b.width / 2},${b.height - 1} 1,${b.height / 2}"/></svg>`
654
+ : '') +
623
655
  `<span class="nm"><span class="t">${escapeHtml(st.label)}</span></span>` +
624
656
  `<span class="l2">` +
625
657
  (st.state !== undefined
@@ -636,7 +668,7 @@ function renderFlowSection(level, flow, questionCount, drawableNodes) {
636
668
  })
637
669
  .join('');
638
670
  return (`<section class="level flow-level" data-level-id="${escapeHtml(level.id)}" data-flow-id="${escapeHtml(level.flowId)}" ` +
639
- `aria-label="${escapeHtml(flow.title)}" data-w="${width}" data-h="${height}" style="width:${width}px;height:${height}px" hidden>` +
671
+ `aria-label="${escapeHtml(solo !== undefined ? `${flow.title}, 행위자 ${solo.actor.label}` : flow.title)}" data-w="${width}" data-h="${height}" style="width:${width}px;height:${height}px" hidden>` +
640
672
  `<svg class="links" xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" ` +
641
673
  `role="group" aria-label="${escapeHtml(`${flow.title} 전이`)}"><g class="lanes">${lanes}${dividers}</g><g class="edges">${links}</g></svg>` +
642
674
  `${stageHeads}${heads}${cards}</section>`);
@@ -812,6 +844,206 @@ function stickyHeadBar(heads, width) {
812
844
  const h = heads.reduce((m, b) => Math.max(m, b.height), 0);
813
845
  return `<div class="seq-sticky" style="top:${CANVAS_PAD_TOP - STICKY_PAD}px;width:${width}px;height:${h + STICKY_PAD * 2}px"></div>`;
814
846
  }
847
+ /** 순서도 메시지의 읽기 이름. 순서도 보기와 같은 꼴이다 */
848
+ function sequenceMessageName(n, m, nameOf) {
849
+ return (`${n}. ${nameOf(m.from)} → ${nameOf(m.to)}: ${m.label}` +
850
+ (m.reply ? ', 응답' : '') +
851
+ (m.lineStyle === 'dashed' ? ', 문서로만 확인' : ''));
852
+ }
853
+ /**
854
+ * 순서도 옆에 두는 두 보기, 단계별 카드와 따라가기. 순서도 섹션은 그대로 두고 형제 섹션으로 붙인다.
855
+ * data-level-id가 없어 레벨 목록에 안 잡히고 보기 단추로만 바뀐다
856
+ */
857
+ function renderSequenceAlts(levelId, projection, layout, drawn, nodeById, services, whereOf, edges) {
858
+ const nameOf = (id) => {
859
+ const n = nodeById.get(id);
860
+ return n ? nodeName(n) : id;
861
+ };
862
+ const order = layout.heads.map((h) => h.id);
863
+ const phases = resolveSequencePhases(projection, drawn, order, nameOf);
864
+ if (phases.length === 0)
865
+ return undefined;
866
+ const messages = projection.messages.filter((m) => drawn.has(m.id));
867
+ const numberById = new Map(layout.messages.map((b) => [b.id, b.n]));
868
+ const numberOf = (id) => numberById.get(id) ?? 0;
869
+ const blockText = new Map((projection.blocks ?? []).map((b) => [b.id, `${SEQUENCE_BLOCK_TEXT[b.kind]} [${b.label}]`]));
870
+ const of = escapeHtml(levelId);
871
+ const cards = renderPhaseCards(of, projection, phases, numberOf, nodeById, nameOf);
872
+ const walk = renderWalk(of, projection, phases, messages, layout, nodeById, services, whereOf, edges, numberOf);
873
+ const phaseOf = new Map(phases.flatMap((ph) => ph.messageIds.map((id) => [id, ph])));
874
+ const messageById = new Map(messages.map((m) => [m.id, m]));
875
+ const steps = phases
876
+ .map((ph) => {
877
+ const items = ph.messageIds
878
+ .map((id) => {
879
+ const m = messageById.get(id);
880
+ const say = `${m.reply ? '↩ ' : ''}${m.label}`;
881
+ const block = m.block !== undefined
882
+ ? `${blockText.get(m.block) ?? ''}${m.branch !== undefined ? `, ${m.branch}` : ''}`
883
+ : '';
884
+ return (`<li><button type="button" data-n="${numberOf(id)}" data-phase="${escapeHtml(phaseOf.get(id).label)}" ` +
885
+ `data-say="${escapeHtml(say)}" data-block="${escapeHtml(block)}">${numberOf(id)}. ${escapeHtml(say)}</button></li>`);
886
+ })
887
+ .join('');
888
+ return `<li class="ss-phase"><h4>${escapeHtml(ph.label)}</h4><ol>${items}</ol></li>`;
889
+ })
890
+ .join('');
891
+ return {
892
+ sections: `${cards}\n${walk}`,
893
+ steps: `<ol class="ss-list" data-seq-of="${of}" hidden>${steps}</ol>`,
894
+ };
895
+ }
896
+ function seqAltSection(levelIdAttr, mode, name, projection, width, height, body) {
897
+ return (`<section class="level view-level seq-alt seq-${mode}" data-seq-of="${levelIdAttr}" data-seq-mode="${mode}" ` +
898
+ `aria-label="${escapeHtml(`${projection.title} ${name}`)}" data-w="${width}" data-h="${height}" ` +
899
+ `style="width:${width}px;height:${height}px" hidden>` +
900
+ `<p class="view-q" style="left:${CANVAS_PAD_X}px">${escapeHtml(projection.question)}</p>${body}</section>`);
901
+ }
902
+ const SEQ_ALT_BOTTOM = 32;
903
+ const MINI_ARROW = 6;
904
+ /** 단계별 카드. 카드마다 그 구간 참여자만 가진 작은 순서도를 그리고 카드 사이에 화살표를 둔다 */
905
+ function renderPhaseCards(levelIdAttr, projection, phases, numberOf, nodeById, nameOf) {
906
+ const board = computePhaseBoard(phases, projection, numberOf);
907
+ const px = (x) => round2(x + CANVAS_PAD_X);
908
+ const py = (y) => round2(y + CANVAS_PAD_TOP);
909
+ const width = round2(board.width + CANVAS_PAD_X * 2);
910
+ const height = round2(board.height + CANVAS_PAD_TOP + SEQ_ALT_BOTTOM);
911
+ const phaseById = new Map(phases.map((ph) => [ph.id, ph]));
912
+ const messageById = new Map(projection.messages.map((m) => [m.id, m]));
913
+ const cards = board.cards
914
+ .map((card) => {
915
+ const ph = phaseById.get(card.phaseId);
916
+ const title = `${card.index}. ${ph.label}`;
917
+ return (`<div class="seq-phase" role="group" aria-label="${escapeHtml(title)}" ` +
918
+ `style="left:${px(card.x)}px;top:${py(card.y)}px;width:${card.width}px;height:${card.height}px">` +
919
+ `<h3 title="${escapeHtml(title)}">${escapeHtml(title)}</h3><p class="sp-meta">${phaseMetaText(ph)}</p>` +
920
+ miniSequenceSvg(card, projection, messageById, nodeById, nameOf) +
921
+ `</div>`);
922
+ })
923
+ .join('');
924
+ const arrows = board.arrows
925
+ .map((a) => `<span class="sp-arrow" aria-hidden="true" style="left:${round2(px(a.x) - 18)}px;top:${round2(py(a.y) - 14)}px">→</span>`)
926
+ .join('');
927
+ return seqAltSection(levelIdAttr, 'cards', '단계별 카드', projection, width, height, cards + arrows);
928
+ }
929
+ function miniSequenceSvg(card, projection, messageById, nodeById, nameOf) {
930
+ const s = card.svg;
931
+ const blocks = s.blocks
932
+ .map((b) => {
933
+ const tag = SEQUENCE_BLOCK_TEXT[b.kind];
934
+ const tagW = textUnits(tag) * 6 + 12;
935
+ const room = Math.max(0, Math.floor((b.width - tagW - 20) / 6) - 2);
936
+ const branches = b.branches
937
+ .map((br) => `<line class="b-branch" x1="${b.x}" x2="${round2(b.x + b.width)}" y1="${br.y}" y2="${br.y}"/>` +
938
+ (br.label !== ''
939
+ ? `<text class="b-label" x="${b.x + 8}" y="${round2(br.y + 11)}">[${escapeHtml(clipText(br.label, room))}]</text>`
940
+ : ''))
941
+ .join('');
942
+ return (`<g class="seq-block b-${b.kind}"><rect x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" rx="6"/>` +
943
+ `<path class="b-tab" d="M${b.x} ${round2(b.y + 14)}H${b.x + tagW}L${b.x + tagW + 6} ${round2(b.y + 8)}V${b.y}"/>` +
944
+ `<text class="b-kind" x="${b.x + 6}" y="${round2(b.y + 11)}">${tag}</text>` +
945
+ `<text class="b-label" x="${b.x + tagW + 12}" y="${round2(b.y + 11)}"><title>[${escapeHtml(b.label)}]</title>[${escapeHtml(clipText(b.label, room))}]</text>` +
946
+ `${branches}</g>`);
947
+ })
948
+ .join('');
949
+ const heads = s.heads
950
+ .map((h) => {
951
+ const n = nodeById.get(h.id);
952
+ const name = nameOf(h.id);
953
+ return (`<line class="sp-life" x1="${h.cx}" x2="${h.cx}" y1="${s.lifeline.y1}" y2="${s.lifeline.y2}"/>` +
954
+ `<g class="sp-head${n ? ` k-${displayKindOf(n)}` : ''}"><title>${escapeHtml(name)}</title>` +
955
+ `<rect x="${h.x}" y="${h.y}" width="${h.width}" height="${h.height}" rx="7"/>` +
956
+ `<text x="${h.cx}" y="${round2(h.y + h.height / 2 + 4)}" text-anchor="middle">${escapeHtml(clipText(name, 17))}</text></g>`);
957
+ })
958
+ .join('');
959
+ const links = s.messages
960
+ .map((box) => {
961
+ const m = messageById.get(box.id);
962
+ const dash = m.lineStyle === 'dashed' ? ` stroke-dasharray="${DASHED_PATTERN}"` : '';
963
+ const y = box.y;
964
+ let d;
965
+ let tip;
966
+ let labelX;
967
+ let labelY;
968
+ if (box.self) {
969
+ const x = box.x1;
970
+ const yb = round2(y + 4);
971
+ d = `M${x} ${round2(y - 6)}H${round2(x + 18)}V${yb}H${round2(x + 2)}`;
972
+ tip = [
973
+ `${round2(x + 2)},${yb}`,
974
+ `${round2(x + 2 + MINI_ARROW)},${round2(yb - MINI_ARROW / 2)}`,
975
+ `${round2(x + 2 + MINI_ARROW)},${round2(yb + MINI_ARROW / 2)}`,
976
+ ];
977
+ labelX = round2(x + 24);
978
+ labelY = round2(y + 2);
979
+ }
980
+ else {
981
+ const back = round2(box.x2 - (box.x2 > box.x1 ? 1 : -1) * MINI_ARROW);
982
+ d = `M${box.x1} ${y}H${box.x2}`;
983
+ tip = [
984
+ `${box.x2},${y}`,
985
+ `${back},${round2(y - MINI_ARROW / 2)}`,
986
+ `${back},${round2(y + MINI_ARROW / 2)}`,
987
+ ];
988
+ labelX = round2(Math.min(box.x1, box.x2) + 6);
989
+ labelY = round2(y - 4);
990
+ }
991
+ const name = sequenceMessageName(box.n, m, nameOf);
992
+ const head = m.reply
993
+ ? `<polyline class="tip open" points="${tip[1]} ${tip[0]} ${tip[2]}"/>`
994
+ : `<polygon class="tip" points="${tip.join(' ')}"/>`;
995
+ return (`<g class="link seq-m${m.reply ? ' reply' : ''}" data-from="${escapeHtml(m.from)}" data-to="${escapeHtml(m.to)}" ` +
996
+ `data-message-id="${escapeHtml(m.id)}" tabindex="0" role="button" aria-label="${escapeHtml(name)}">` +
997
+ `<title>${escapeHtml(name)}</title><path class="hit" d="${d}" stroke-width="10"/>` +
998
+ `<path class="edge" d="${d}" stroke-width="1.2"${dash}/>${head}` +
999
+ `<text class="m-label" x="${labelX}" y="${labelY}"><tspan class="m-n">${box.n}.</tspan> ${escapeHtml(clipText(m.label, box.labelUnits))}</text></g>`);
1000
+ })
1001
+ .join('');
1002
+ return (`<svg class="sp-seq" xmlns="http://www.w3.org/2000/svg" width="${s.width}" height="${s.height}" viewBox="0 0 ${s.width} ${s.height}" ` +
1003
+ `role="group" aria-label="${escapeHtml(`${projection.title} 메시지`)}">${blocks}${heads}${links}</svg>`);
1004
+ }
1005
+ /** 따라가기. 순서도 참여자만으로 만든 작은 지도 위에 단계마다 화살표 하나를 켠다. 어느 화살표를 켤지는 클라이언트가 정한다 */
1006
+ function renderWalk(levelIdAttr, projection, phases, messages, layout, nodeById, services, whereOf, edges, numberOf) {
1007
+ const sizes = new Map(layout.heads.map((h) => [h.id, { width: h.width, height: h.height }]));
1008
+ const depths = sequenceCallDepths(messages, layout.heads[0]?.id);
1009
+ const walk = computeWalkLayout(phases, messages, depths, (id) => sizes.get(id), new Map(edges.map((e) => [e.id, e])), numberOf);
1010
+ const width = round2(walk.width + CANVAS_PAD_X * 2);
1011
+ const height = round2(walk.height + CANVAS_PAD_TOP + SEQ_ALT_BOTTOM);
1012
+ const tipId = escapeHtml(`walk-tip-${projection.id}`);
1013
+ const bands = walk.bands
1014
+ .map((b) => `<text class="w-band" x="${b.x}" y="${b.y}">${escapeHtml(b.label)}</text>`)
1015
+ .join('');
1016
+ const lines = walk.edges.map((e) => `<path class="w-edge" d="${e.d}"/>`).join('');
1017
+ const steps = walk.steps
1018
+ .map((st) => `<path class="w-step" data-n="${st.n}" data-from="${escapeHtml(st.from)}" data-to="${escapeHtml(st.to)}" ` +
1019
+ `data-message-id="${escapeHtml(st.messageId)}" d="${st.d}" marker-end="url(#${tipId})"/>`)
1020
+ .join('');
1021
+ const svg = `<svg class="links" xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" ` +
1022
+ `role="group" aria-label="${escapeHtml(`${projection.title} 따라가기 지도`)}">` +
1023
+ `<defs><marker id="${tipId}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">` +
1024
+ `<path class="w-tip" d="M0 0L10 5L0 10z"/></marker></defs>` +
1025
+ `<g transform="translate(${CANVAS_PAD_X} ${CANVAS_PAD_TOP})"><g class="w-bands">${bands}</g><g class="w-edges">${lines}</g>` +
1026
+ `<g class="w-steps">${steps}</g></g></svg>`;
1027
+ const cards = viewCards(walk.nodes, nodeById, services, whereOf);
1028
+ return seqAltSection(levelIdAttr, 'walk', '따라가기', projection, width, height, svg + cards);
1029
+ }
1030
+ /** 순서도 보기 전환 단추와 따라가기 자막. 순서도가 있는 그림에만 싣고 순서도 레벨에서만 보인다 */
1031
+ function renderSequenceUi(stepLists) {
1032
+ const mode = (id, text, on) => `<button type="button" class="btn" data-seq-mode="${id}" aria-pressed="${on}">${text}</button>`;
1033
+ return (`<div id="seq-bar" class="seq-bar" hidden>` +
1034
+ `<div class="seq-modes" role="group" aria-label="순서도 보기">` +
1035
+ mode('seq', '순서도', true) +
1036
+ mode('cards', '단계별 카드', false) +
1037
+ mode('walk', '따라가기', false) +
1038
+ `</div>` +
1039
+ `<div class="seq-cap" hidden><span class="sw-say" aria-live="polite"><span class="sw-n"></span><span class="sw-p"></span>` +
1040
+ `<span class="sw-t"></span><span class="sw-b" hidden></span></span>` +
1041
+ `<span class="sw-ctl"><button type="button" class="btn icon sw-prev" aria-label="이전 단계" title="이전 단계 (←)">◀</button>` +
1042
+ `<button type="button" class="btn sw-play" aria-pressed="false">재생</button>` +
1043
+ `<button type="button" class="btn icon sw-next" aria-label="다음 단계" title="다음 단계 (→)">▶</button></span></div>` +
1044
+ `</div>\n` +
1045
+ `<nav id="seq-steps" class="seq-steps" aria-label="단계 목록" hidden>${stepLists.join('')}</nav>`);
1046
+ }
815
1047
  /** 데이터가 어디서 어디로 옮겨 가는지. 선 라벨이 옮겨 가는 데이터 이름이다 */
816
1048
  function renderDataflowSection(levelId, projection, layout, nodeById, services) {
817
1049
  const px = (x) => round2(x + CANVAS_PAD_X);
@@ -912,7 +1144,7 @@ function flowPayload(levels, flows, questions) {
912
1144
  };
913
1145
  });
914
1146
  }
915
- function renderLegend(nodes, edges, drill, generatedAt, nodeCount, edgeCount, hasFlows = false) {
1147
+ function renderLegend(nodes, edges, drill, generatedAt, nodeCount, edgeCount, hasFlows = false, hasDecisions = false) {
916
1148
  const nodeById = new Map(nodes.map((n) => [n.id, n]));
917
1149
  const line = (extra) => `<svg width="36" height="12" aria-hidden="true"><line x1="3" y1="6" x2="33" y2="6" ${extra}/></svg>`;
918
1150
  const kinds = [...new Set(nodes.map(displayKindOf))]
@@ -933,6 +1165,9 @@ function renderLegend(nodes, edges, drill, generatedAt, nodeCount, edgeCount, ha
933
1165
  ? `<li>${line('stroke-width="2" class="side-line"')}주황 선: 흐름에서 정상 흐름을 벗어나는 옆 흐름</li>` +
934
1166
  `<li><span class="swatch-step"></span>점선 테두리 카드: 기획 문서로만 확인한 흐름 단계</li>`
935
1167
  : '') +
1168
+ (hasDecisions
1169
+ ? `<li><span class="swatch-decision"></span>마름모: 조건에 따라 길이 갈리는 갈림길</li>`
1170
+ : '') +
936
1171
  `</ul>` +
937
1172
  (kinds ? `<h3>항목</h3><ul class="legend-kinds">${kinds}</ul>` : '') +
938
1173
  `<p class="foot">만든 시각 ${escapeHtml(generatedAt)}<br>항목 ${nodeCount}개, 연결 ${edgeCount}개</p></div>`);
@@ -1057,8 +1292,8 @@ function renderBar(title, drill, generatedAt, nodeCount, edgeCount, questionCoun
1057
1292
  }
1058
1293
  const clientScripts = new Map();
1059
1294
  /** 칩 글자와 설명, 레인 제목은 IR이 쓰는 팩 것만 싣는다. 팩을 더해도 그 팩을 안 쓰는 그림의 바이트가 그대로다 */
1060
- function clientScriptFor(vocab, rootFlows, views, mixedBundles) {
1061
- const key = `${vocab.packIds.join(',')}|${rootFlows}|${views}|${mixedBundles}`;
1295
+ function clientScriptFor(vocab, rootFlows, views, mixedBundles, sequences, flowBranches) {
1296
+ const key = `${vocab.packIds.join(',')}|${rootFlows}|${views}|${mixedBundles}|${sequences}|${flowBranches}`;
1062
1297
  const hit = clientScripts.get(key);
1063
1298
  if (hit !== undefined)
1064
1299
  return hit;
@@ -1090,6 +1325,8 @@ function clientScriptFor(vocab, rootFlows, views, mixedBundles) {
1090
1325
  rootFlows,
1091
1326
  views,
1092
1327
  mixedBundles,
1328
+ sequences,
1329
+ ...(flowBranches ? { flowBranches } : {}),
1093
1330
  docs: vocab.packIds.includes('knowledge'),
1094
1331
  });
1095
1332
  clientScripts.set(key, script);
@@ -1103,12 +1340,21 @@ function renderViewsPop(views) {
1103
1340
  .join('');
1104
1341
  return `<div id="views" class="pop" role="dialog" aria-label="질문별 그림" tabindex="-1" hidden><h3>질문별 그림</h3><ul class="flow-list view-list">${items}</ul></div>`;
1105
1342
  }
1106
- function renderPage({ ir, payload, sections, drill, flows, views, mixedBundles = false, }) {
1343
+ function renderPage({ ir, payload, sections, drill, flows, views, mixedBundles = false, sequences = [], }) {
1107
1344
  const title = viewTitle(ir);
1108
1345
  const vocab = irVocabulary(ir);
1109
1346
  const nodeById = new Map(payload.nodes.map((n) => [n.id, n]));
1110
1347
  const hasFlows = flows !== undefined && flows.flows.length > 0;
1111
1348
  const hasViews = views !== undefined && views.length > 0;
1349
+ const hasSequences = sequences.length > 0;
1350
+ const drawnFlows = flows?.flows ?? [];
1351
+ const hasDecisions = drawnFlows.some((f) => f.steps.some((st) => st.kind === 'decision' && flows.drawnSteps.has(st.id)));
1352
+ // 누른 것과 조건, 갈림길, 행위자 한 명 띠는 그 흐름이 있을 때만 CSS와 코드를 싣는다. 예전 흐름 그림의 바이트를 지키려고서다
1353
+ const flowBranches = hasDecisions ||
1354
+ drawnFlows.some((f) => f.transitions.some((t) => (t.trigger !== undefined || t.condition !== undefined) &&
1355
+ flows.drawnSteps.has(t.from) &&
1356
+ flows.drawnSteps.has(t.to)));
1357
+ const soloActor = drawnFlows.some((f) => f.actors.length === 1);
1112
1358
  return [
1113
1359
  '<!doctype html>',
1114
1360
  '<html lang="ko">',
@@ -1117,7 +1363,7 @@ function renderPage({ ir, payload, sections, drill, flows, views, mixedBundles =
1117
1363
  '<meta name="viewport" content="width=device-width, initial-scale=1">',
1118
1364
  `<title>${escapeHtml(title)}</title>`,
1119
1365
  `<script>${THEME_BOOT_SCRIPT}</script>`,
1120
- `<style>${renderCss(vocab)}${hasViews ? `\n${VIEW_CSS}` : ''}${vocab.packIds.includes('knowledge') ? `\n${DOC_CSS}` : ''}</style>`,
1366
+ `<style>${renderCss(vocab)}${hasViews ? `\n${VIEW_CSS}` : ''}${hasSequences ? `\n${SEQ_VIEW_CSS}` : ''}${vocab.packIds.includes('knowledge') ? `\n${DOC_CSS}` : ''}${flowBranches || soloActor ? `\n${FLOW_BRANCH_CSS}` : ''}</style>`,
1121
1367
  '</head>',
1122
1368
  '<body>',
1123
1369
  renderIconSprite(vocab),
@@ -1131,19 +1377,20 @@ function renderPage({ ir, payload, sections, drill, flows, views, mixedBundles =
1131
1377
  '</div>',
1132
1378
  '</div>',
1133
1379
  `<p id="hint" class="hint"><span>${drill ? HINT_DRILL : HINT_FLAT}</span>` +
1134
- `<button type="button" id="hint-close" aria-label="안내 닫기">${iconUse('u-close')}</button></p>`,
1380
+ `<button type="button" id="hint-close" aria-label="안내 닫기">${iconUse('u-close')}</button></p>` +
1381
+ (hasSequences ? `\n${renderSequenceUi(sequences)}` : ''),
1135
1382
  '<details id="sheet" class="sheet" hidden><summary id="sheet-title">세부 연결</summary><div id="sheet-body" class="scroll"></div></details>',
1136
1383
  '<aside id="drawer" class="drawer" role="dialog" aria-modal="false" aria-labelledby="drawer-title" aria-hidden="true" inert>' +
1137
1384
  `<button type="button" id="drawer-close" class="btn icon dr-close" aria-label="닫기">${iconUse('u-close')}</button>` +
1138
1385
  '<div id="panel" class="panel" aria-live="polite"></div></aside>',
1139
1386
  '</div>',
1140
- renderLegend(payload.nodes, payload.edges, drill, ir.generatedAt, payload.nodes.length, payload.edges.length, hasFlows),
1387
+ renderLegend(payload.nodes, payload.edges, drill, ir.generatedAt, payload.nodes.length, payload.edges.length, hasFlows, hasDecisions),
1141
1388
  renderQuestions(payload.unresolved, nodeById, ir.edges, flows?.flows ?? [], flows?.drawnSteps ?? new Set(), views?.flatMap((v) => v.projection.messages)),
1142
1389
  (hasFlows
1143
1390
  ? '<div id="flow-pop" class="pop" role="dialog" aria-label="흐름 고르기" tabindex="-1" hidden><h3>흐름</h3><ul class="flow-list"></ul></div>'
1144
1391
  : '') + (hasViews ? `\n${renderViewsPop(views)}` : ''),
1145
1392
  `<script id="ir" type="application/json">${embedJson(stableStringify(payload))}</script>`,
1146
- `<script>${clientScriptFor(vocab, hasFlows && flows.flows.some((f) => f.service === undefined), hasViews, mixedBundles)}</script>`,
1393
+ `<script>${clientScriptFor(vocab, hasFlows && flows.flows.some((f) => f.service === undefined), hasViews, mixedBundles, hasSequences, flowBranches)}</script>`,
1147
1394
  '</body>',
1148
1395
  '</html>',
1149
1396
  '',
@@ -1240,6 +1487,7 @@ export function renderDrilldownHtml(validated, drilldown, opts) {
1240
1487
  messages: messagePayload(views, drawnMessages, base.unresolved, ir.edges),
1241
1488
  });
1242
1489
  }
1490
+ const sequenceSteps = [];
1243
1491
  const rootFlowLinks = flowLevels
1244
1492
  .filter(({ flow }) => flow.service === undefined)
1245
1493
  .map(({ level, flow }) => ({ level: level.id, title: flow.title }))
@@ -1288,7 +1536,12 @@ export function renderDrilldownHtml(validated, drilldown, opts) {
1288
1536
  return renderCompareSection(v.level, p, layout, nodeById, base.services);
1289
1537
  }
1290
1538
  const layout = computeSequenceLayout(p, drawnMessages, nodeById, whereOf);
1291
- return renderSequenceSection(v.level, p, layout, nodeById, base.services, whereOf);
1539
+ const section = renderSequenceSection(v.level, p, layout, nodeById, base.services, whereOf);
1540
+ const alts = renderSequenceAlts(v.level, p, layout, drawnMessages, nodeById, base.services, whereOf, ir.edges);
1541
+ if (!alts)
1542
+ return section;
1543
+ sequenceSteps.push(alts.steps);
1544
+ return `${section}\n${alts.sections}`;
1292
1545
  }))
1293
1546
  .join('\n');
1294
1547
  return renderPage({
@@ -1299,6 +1552,7 @@ export function renderDrilldownHtml(validated, drilldown, opts) {
1299
1552
  ...(flowLevels.length > 0 ? { flows: { flows: drawnFlows, drawnSteps } } : {}),
1300
1553
  ...(views.length > 0 ? { views } : {}),
1301
1554
  mixedBundles: levels.some((l) => l.edges.some((e) => e.inferred !== undefined)),
1555
+ ...(sequenceSteps.length > 0 ? { sequences: sequenceSteps } : {}),
1302
1556
  });
1303
1557
  }
1304
1558
  //# sourceMappingURL=html-renderer.js.map