a850745f85
flat 1030 절뷰를 read-time 표현계층에서 front-matter 단일 접이그룹 + PART/APPENDIX 접이그룹 (기본 전부 접힘)으로. 빌더/재분해 무접촉, 검색 무관(in_corpus=false 불변). - partitionOutlineItems: 순서기반 carry-forward 그룹핑(비-PART top-segment 항목은 직전 PART 흡수). buildPartOutline = partitionOutlineItems∘collapseWindows 로 통일. PART_MARKER_RE = case-sensitive PART/SUBSECTION/APPENDIX(+대문자제목 가드) — 본문 cross-ref/문장 false match 차단 (5210 'Part D…'·'PART UW 규정은…' 거부). 한글제목 PART 미인식은 D3 재정련(주석 박제). - partGroupViews/groupKeyByChunkId: front-matter 첫 그룹 평탄화 + auto-expand 역인덱스. - SectionOutline.svelte: Part 접이 모드 + groupOrFlat 폴백 + activeKey auto-expand. - [id]/+page.svelte: treeNav 그룹 접이(treeNode 스니펫·d3 시안 보존) + 기본선택=첫 본문 Part + selectedSectionId auto-expand. 데스크탑/모바일 treeNav 공유. - 리뷰 반영: rail max-height calc() 공백 fix / treeNode a11y role 조건부 / 문서 전환 접이상태 리셋 / 모바일 본문 스코프 주석. real-data 검증(prod read-only): 5180 → front-matter231 + 15 PART + 6 APPENDIX = 22 접이그룹· 커버리지 1030/1030·PG-27 정상. 5210(D3 재분해 전 stale) → 깨끗 PART 0 → hasParts=false → flat 폴백(무회귀). 단위 26/26, vite build PASS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
186 lines
7.2 KiB
Svelte
186 lines
7.2 KiB
Svelte
<script lang="ts">
|
||
// 문서 상세 좌측 절(section) 목차 (PR-DocSrv-Hier-Section-UI-1).
|
||
// - ASME 등 구조화 코드(buildPartOutline.hasParts): front-matter 단일 접이그룹 + PART 접이
|
||
// (기본 접힘, 1030 flat → ~14 top-level). scroll-spy/딥링크 진입 시 조상 PART auto-expand. (D8)
|
||
// - 그 외(per-doc): groupOrFlat 폴백 — top-segment 1단 그룹 vs flat(5140/5186/비-ASME 무회귀).
|
||
// - 항목 클릭 → 인라인 아코디언으로 요약/section_type/heading_path breadcrumb 표시.
|
||
import { untrack } from 'svelte';
|
||
import Badge from '$lib/components/ui/Badge.svelte';
|
||
import {
|
||
cleanHeading,
|
||
pathSegments,
|
||
groupOrFlat,
|
||
buildPartOutline,
|
||
partGroupViews,
|
||
groupKeyByChunkId,
|
||
sectionTypeLabel,
|
||
type DocumentSection,
|
||
type OutlineItem,
|
||
} from '$lib/utils/headingPath';
|
||
|
||
interface Props {
|
||
sections: DocumentSection[];
|
||
/** 항목 클릭 시 본문 점프 콜백(부모가 #sec-{chunkId} scrollIntoView). 없으면 아코디언만. */
|
||
onJump?: (chunkId: number) => void;
|
||
/** scroll-spy 현재 절(chunk_id) — 강조 + Part auto-expand. */
|
||
activeKey?: number | null;
|
||
}
|
||
let { sections, onJump, activeKey = null }: Props = $props();
|
||
|
||
let partOutline = $derived(buildPartOutline(sections));
|
||
// hasParts(ASME 등): Part 접이 모드. 아니면 partViews=null → groupOrFlat 폴백.
|
||
let partViews = $derived(partOutline.hasParts ? partGroupViews(partOutline) : null);
|
||
let layout = $derived.by(() => (partOutline.hasParts ? null : groupOrFlat(sections)));
|
||
let groupIndex = $derived(partViews ? groupKeyByChunkId(partViews) : null);
|
||
let total = $derived(sections.length);
|
||
|
||
let selectedId = $state<number | null>(null);
|
||
// Part 그룹 접이 상태: key 없으면 접힘(기본 전부 접힘). $state Record = Svelte5 deep-proxy 반응형.
|
||
let expanded = $state<Record<string, boolean>>({});
|
||
function toggleGroup(key: string) {
|
||
expanded[key] = !expanded[key];
|
||
}
|
||
// 문서 전환(DocumentViewer 가 sections prop 교체) 시 접이/선택 리셋 — 문서 간 PART 라벨/chunk_id 가
|
||
// 우연히 겹쳐 이전 펼침/선택이 이월되는 것 차단(기본 전부 접힘 불변식 보존). untrack=쓰기 자기재발화 차단.
|
||
$effect(() => {
|
||
void sections;
|
||
untrack(() => { expanded = {}; selectedId = null; });
|
||
});
|
||
// scroll-spy/딥링크 활성 절의 조상 Part 를 펼침(다른 그룹은 건드리지 않음). untrack=쓰기 자기재발화 차단.
|
||
$effect(() => {
|
||
const ak = activeKey;
|
||
const idx = groupIndex;
|
||
if (ak == null || !idx) return;
|
||
const gk = idx.get(ak);
|
||
if (gk) untrack(() => { expanded[gk] = true; });
|
||
});
|
||
|
||
function toggle(item: OutlineItem) {
|
||
const id = item.section.chunk_id;
|
||
selectedId = selectedId === id ? null : id;
|
||
}
|
||
function title(s: DocumentSection): string {
|
||
return cleanHeading(s.section_title) || pathSegments(s.heading_path).at(-1) || '(제목 없음)';
|
||
}
|
||
function isLowConf(s: DocumentSection): boolean {
|
||
return s.confidence != null && s.confidence < 0.5;
|
||
}
|
||
</script>
|
||
|
||
{#snippet itemRow(item: OutlineItem)}
|
||
{@const s = item.section}
|
||
{@const open = selectedId === s.chunk_id}
|
||
{@const active = activeKey != null && activeKey === s.chunk_id}
|
||
{@const typeLabel = sectionTypeLabel(s.section_type)}
|
||
{@const depth = Math.max(0, (s.level ?? 1) - 1)}
|
||
<li>
|
||
<button
|
||
type="button"
|
||
onclick={() => { toggle(item); onJump?.(s.chunk_id); }}
|
||
aria-expanded={open}
|
||
aria-current={active ? 'true' : undefined}
|
||
style="padding-left:{8 + depth * 13}px"
|
||
class={[
|
||
'w-full text-left pr-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors border-l-2',
|
||
depth > 0 ? 'text-[11px]' : '',
|
||
open ? 'bg-surface-active text-text border-accent' : active ? 'bg-surface text-accent-hover border-accent' : 'text-dim hover:bg-surface hover:text-text border-transparent',
|
||
].join(' ')}
|
||
>
|
||
<span class="flex-1 min-w-0 leading-snug break-words">{title(s)}</span>
|
||
<span class="flex items-center gap-1 shrink-0">
|
||
{#if item.fragmentCount > 1}
|
||
<Badge tone="neutral" size="sm">{item.fragmentCount}조각</Badge>
|
||
{/if}
|
||
{#if typeLabel}
|
||
<Badge tone="accent" size="sm">{typeLabel}</Badge>
|
||
{/if}
|
||
</span>
|
||
</button>
|
||
{#if open}
|
||
<div class="px-2 pb-2 pt-1 text-xs">
|
||
{#if pathSegments(s.heading_path).length}
|
||
<div class="text-faint mb-1 leading-snug break-words">
|
||
{pathSegments(s.heading_path).join(' › ')}
|
||
</div>
|
||
{/if}
|
||
{#if s.summary}
|
||
<p class="text-text leading-relaxed whitespace-pre-line">{s.summary}</p>
|
||
{#if isLowConf(s)}
|
||
<div class="mt-1.5">
|
||
<Badge tone="warning" size="sm">저신뢰 — 표 추출이 불완전할 수 있음</Badge>
|
||
</div>
|
||
{/if}
|
||
{:else}
|
||
<p class="text-faint italic">요약 없음 — 짧은 절이거나 아직 분석되지 않았습니다.</p>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</li>
|
||
{/snippet}
|
||
|
||
<div class="text-xs">
|
||
<h3 class="text-xs font-semibold text-dim uppercase mb-2 flex items-center justify-between">
|
||
<span>절 목차</span>
|
||
<span class="text-faint font-normal">{total}</span>
|
||
</h3>
|
||
|
||
{#if partViews}
|
||
<!-- Part 접이 모드 (ASME 등): front-matter 단일 그룹 + PART 접이, 기본 접힘 -->
|
||
<div class="space-y-1">
|
||
{#each partViews as g (g.key)}
|
||
{@const isOpen = !!expanded[g.key]}
|
||
<div>
|
||
<button
|
||
type="button"
|
||
onclick={() => toggleGroup(g.key)}
|
||
aria-expanded={isOpen}
|
||
class={[
|
||
'w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-semibold uppercase tracking-wide transition-colors',
|
||
g.isFrontMatter ? 'text-faint' : 'text-dim',
|
||
'hover:bg-surface hover:text-text',
|
||
].join(' ')}
|
||
>
|
||
<span class="shrink-0 transition-transform duration-150 {isOpen ? 'rotate-90' : ''}">›</span>
|
||
<span class="flex-1 min-w-0 text-left truncate normal-case">{g.label}</span>
|
||
<span class="font-normal text-faint">{g.items.length}</span>
|
||
</button>
|
||
{#if isOpen}
|
||
<ul class="space-y-0.5 mt-0.5">
|
||
{#each g.items as item (item.section.chunk_id)}
|
||
{@render itemRow(item)}
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{:else if layout?.mode === 'group'}
|
||
<div class="space-y-3">
|
||
{#each layout.groups as g (g.key)}
|
||
<div>
|
||
<div
|
||
class={[
|
||
'px-2 mb-1 text-[11px] font-semibold uppercase tracking-wide',
|
||
g.isOther ? 'text-faint' : 'text-dim',
|
||
].join(' ')}
|
||
>
|
||
{g.key}
|
||
<span class="font-normal text-faint">({g.items.length})</span>
|
||
</div>
|
||
<ul class="space-y-0.5">
|
||
{#each g.items as item (item.section.chunk_id)}
|
||
{@render itemRow(item)}
|
||
{/each}
|
||
</ul>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{:else}
|
||
<ul class="space-y-0.5">
|
||
{#each layout?.items ?? [] as item (item.section.chunk_id)}
|
||
{@render itemRow(item)}
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</div>
|