| import * as d3 from 'd3'; |
| import { DirectedGraph } from 'graphology'; |
| import type { D3Sel } from '../utils/Util'; |
| import { visualizeSpecialChars } from '../utils/tokenDisplayUtils'; |
| import { |
| clampDagEdgeTopPCoverage, |
| collectGenAttrDagExcludeIntervals, |
| DAG_EDGE_TOP_P_COVERAGE_DEFAULT, |
| excludeNodeAggregatedEntries, |
| phase2RankAndSparsify, |
| type PromptTokenSpan, |
| } from './genAttributeDagPreprocess'; |
| import { DAG_EDGE_MIN_DISPLAY_OPACITY } from './genAttributeDagEdgeDisplay'; |
| import { isOffsetSpanFullyExcluded } from './attributionDisplayModel'; |
| import { |
| alignAndAggregateByNode, |
| clearGenAttributeDagAlignmentWarnDedupe, |
| type NodeInterval, |
| type PieceEntry, |
| } from './genAttributeDagIntervalResolve'; |
| import type { TokenGenStep } from './tokenGenAttributionRunner'; |
| import { createGenAttributeDagTextMeasure } from './genAttributeDagTextMeasure'; |
| import { formatTopkTooltipProbabilityPercent } from '../utils/topkChartUtils'; |
| import { |
| CSS_PSEUDO_FULLSCREEN_CHANGE_EVENT, |
| dagResultsSurfaceFullscreenExpanded, |
| detachDagPseudoFullscreenIfPresent, |
| runDagFullscreenToggleWithPseudoWorkaround, |
| } from './genAttributeDagFullscreenWorkaround'; |
| import { |
| clampLinearArcAdjacentGap, |
| LINEAR_ARC_ADJACENT_GAP_DEFAULT, |
| LINEAR_ARC_ADJACENT_GAP_MAX, |
| LINEAR_ARC_ADJACENT_GAP_MIN, |
| LINEAR_ARC_BEZIER_HANDLE_INSET_FRACTION, |
| paintLinearArcLayout, |
| } from './genAttributeDagViewLinearArcMode'; |
| import { paintTextFlowLayout } from './genAttributeDagViewTextFlowMode'; |
| import { paintSpiralLayout } from './genAttributeDagViewSpiralMode'; |
| import { tr } from '../lang/i18n-lite'; |
|
|
| |
| const detachGenAttributeDagPanel = new WeakMap<HTMLElement, () => void>(); |
|
|
| |
| export type DagLayoutMode = 'text-flow' | 'linear-arc' | 'spiral'; |
|
|
| export const DAG_COMPACTNESS_DEFAULT = 0.5; |
| |
| export const DAG_COMPACTNESS_MIN = 0.05; |
| export const DAG_COMPACTNESS_MAX = 1; |
|
|
| export function clampDagCompactness(n: number): number { |
| if (!Number.isFinite(n)) return DAG_COMPACTNESS_DEFAULT; |
| return Math.min(DAG_COMPACTNESS_MAX, Math.max(DAG_COMPACTNESS_MIN, n)); |
| } |
|
|
| |
| |
| |
| |
| const ZERO_CONFIDENCE_PROBABILITY_BASELINE = 2 ** -20; |
|
|
| function clamp01(n: number): number { |
| return Math.min(1, Math.max(0, n)); |
| } |
|
|
| |
| |
| |
| |
| |
| function computeMutualInformationRatio(targetProb: number | undefined): number { |
| if (targetProb === undefined) return 1; |
| if (!Number.isFinite(targetProb) || targetProb <= 0) return 0; |
|
|
| return clamp01( |
| Math.log2(targetProb / ZERO_CONFIDENCE_PROBABILITY_BASELINE) / |
| Math.log2(1 / ZERO_CONFIDENCE_PROBABILITY_BASELINE) |
| ); |
| } |
|
|
| |
| |
| |
| |
| function formatMutualInformationRatioForTooltip(miRatio: number): string { |
| if (!Number.isFinite(miRatio)) return String(miRatio); |
| return formatTopkTooltipProbabilityPercent(miRatio); |
| } |
|
|
| export { |
| clampLinearArcAdjacentGap, |
| LINEAR_ARC_ADJACENT_GAP_DEFAULT, |
| LINEAR_ARC_ADJACENT_GAP_MAX, |
| LINEAR_ARC_ADJACENT_GAP_MIN, |
| LINEAR_ARC_BEZIER_HANDLE_INSET_FRACTION, |
| }; |
|
|
| |
| type DagNodeAttrs = { |
| id: string; |
| label: string; |
| |
| step: number; |
| |
| |
| |
| |
| start: number; |
| end: number; |
| |
| x: number; |
| y: number; |
| |
| nodeW: number; |
| nodeH: number; |
| |
| displayLabel: string; |
| |
| nativeTitleText: string; |
| }; |
|
|
| type DagNode = DagNodeAttrs; |
|
|
| type DagLink = { |
| source: string; |
| target: string; |
| |
| |
| |
| |
| normalizedScore?: number; |
| |
| mutualInformationRatio?: number; |
| |
| scoreShare?: number; |
| |
| alignmentNote?: string; |
| |
| titleText: string; |
| }; |
|
|
| |
| function dagLinkStrokeOpacity(d: Pick<DagLink, 'normalizedScore' | 'mutualInformationRatio'>): number { |
| return (d.normalizedScore ?? 1) * (d.mutualInformationRatio ?? 1); |
| } |
|
|
| function dagLinkEndpointKey(source: string, target: string): string { |
| return `${source}->${target}`; |
| } |
|
|
| |
| |
| |
| |
| function pruneDagLinksTouchingFullyExcludedNodes( |
| graph: DirectedGraph<DagNodeAttrs>, |
| links: DagLink[], |
| intervals: [number, number][], |
| ): void { |
| if (intervals.length === 0) return; |
|
|
| const incidentEdgeIds = new Set<string>(); |
| graph.forEachNode((nodeId, nodeAttrs) => { |
| if (!isOffsetSpanFullyExcluded(nodeAttrs.start, nodeAttrs.end, intervals)) return; |
| for (const edgeId of graph.inEdges(nodeId)) incidentEdgeIds.add(edgeId); |
| for (const edgeId of graph.outEdges(nodeId)) incidentEdgeIds.add(edgeId); |
| }); |
| if (incidentEdgeIds.size === 0) return; |
|
|
| const removedLinkKeys = new Set<string>(); |
| for (const edgeId of incidentEdgeIds) { |
| if (!graph.hasEdge(edgeId)) continue; |
| const source = graph.source(edgeId); |
| const target = graph.target(edgeId); |
| removedLinkKeys.add(dagLinkEndpointKey(source, target)); |
| graph.dropEdge(edgeId); |
| } |
| if (removedLinkKeys.size === 0) return; |
|
|
| let write = 0; |
| for (const link of links) { |
| if (removedLinkKeys.has(dagLinkEndpointKey(link.source, link.target))) { |
| continue; |
| } |
| links[write++] = link; |
| } |
| links.length = write; |
| } |
|
|
| const SVG_MIN_W = 320; |
| const SVG_MIN_H = 280; |
|
|
| |
| |
| |
| |
| |
| function stackLayoutViewportPx(stackEl: HTMLElement): { w: number; h: number } { |
| return { |
| w: Math.max(stackEl.offsetWidth, SVG_MIN_W), |
| h: Math.max(stackEl.offsetHeight, SVG_MIN_H), |
| }; |
| } |
|
|
| |
| const DAG_INITIAL_ZOOM_BOOST_TEXT_FLOW = 2; |
| |
| const DAG_INITIAL_ZOOM_BOOST_LINEAR_ARC = 4; |
| |
| const DAG_INITIAL_ZOOM_BOOST_SPIRAL = 2; |
|
|
| function dagInitialZoomBoost(mode: DagLayoutMode): number { |
| switch (mode) { |
| case 'text-flow': |
| return DAG_INITIAL_ZOOM_BOOST_TEXT_FLOW; |
| case 'linear-arc': |
| return DAG_INITIAL_ZOOM_BOOST_LINEAR_ARC; |
| case 'spiral': |
| return DAG_INITIAL_ZOOM_BOOST_SPIRAL; |
| default: { |
| const _: never = mode; |
| throw new Error(`genAttributeDagView: unknown DagLayoutMode (${String(_)})`); |
| } |
| } |
| } |
|
|
| |
| const CSS_VAR_DAG_COMPACTNESS = '--gen-attr-dag-compactness'; |
| |
| const CSS_VAR_DISPLAY_SCALE = '--gen-attr-dag-display-scale'; |
| |
| const CSS_VAR_DAG_LINK_STROKE_WIDTH = '--gen-attr-dag-link-stroke-width'; |
|
|
| |
| const CSS_VAR_DAG_NORMAL_LINE_COLOR = '--dag-normal-line-color'; |
| |
| const CSS_VAR_DAG_HIGHLIGHT_LINE_IN = '--dag-highlight-line-color-in'; |
| |
| const CSS_VAR_DAG_HIGHLIGHT_LINE_OUT = '--dag-highlight-line-color-out'; |
|
|
| |
| const DAG_NODE_WEAKEN_OPACITY = 0.5; |
| |
| const DAG_NODE_HIDDEN_OPACITY = 0.1; |
|
|
| |
| const DISABLE_DAG_NODE_TOOLTIPS = false; |
|
|
| |
| |
| |
| |
| const LINK_END_INSET_PER_EM = 0.1; |
|
|
| |
| const MARKER_HALF_H = 5; |
| |
| const MARKER_VW = 10; |
| |
| const MARKER_SIZE = 4; |
|
|
| |
| function dagLinkMarkerElementId(source: string, target: string): string { |
| const s = source.replace(/[^0-9_]/g, '_'); |
| const t = target.replace(/[^0-9_]/g, '_'); |
| return `gen-attr-dag-mk-s${s}-t${t}`; |
| } |
|
|
| |
| function dagLinkDataKey(d: DagLink): string { |
| return dagLinkMarkerElementId(String(d.source), String(d.target)); |
| } |
|
|
| function readDisplayScaleFromCss(el: HTMLElement): number { |
| const raw = getComputedStyle(el).getPropertyValue(CSS_VAR_DISPLAY_SCALE).trim(); |
| if (raw === '') return 1; |
| const n = parseFloat(raw); |
| if (!Number.isFinite(n) || n <= 0) { |
| throw new Error( |
| `genAttributeDagView: ${CSS_VAR_DISPLAY_SCALE} must be a finite positive number, got "${raw}"` |
| ); |
| } |
| return n; |
| } |
|
|
| |
| function linkEndInsetBaseAtUnitScalePx(measureLayerEl: HTMLElement): number { |
| const fs = parseFloat(getComputedStyle(measureLayerEl).fontSize); |
| if (!Number.isFinite(fs) || fs <= 0) { |
| throw new Error('genAttributeDagView: .gen-attr-dag-measure-layer font-size must be a finite positive length'); |
| } |
| return fs * LINK_END_INSET_PER_EM; |
| } |
|
|
| function nodeRx(d: DagNode): number { |
| return Math.min(9, d.nodeW / 2, d.nodeH / 2); |
| } |
|
|
| export type GenAttributeDagHandle = { |
| |
| |
| |
| |
| setPromptTokenSpans(spans: PromptTokenSpan[], promptText: string): void; |
| |
| |
| |
| |
| update(step: TokenGenStep, excludeIntervalContext?: string): void; |
| |
| |
| |
| |
| |
| beginBatch(): void; |
| |
| endBatch(): void; |
| |
| isBatching(): boolean; |
| |
| |
| |
| |
| |
| |
| reset(preserveUserViewport?: boolean): void; |
| |
| |
| |
| |
| |
| |
| |
| fitViewportToContent(force?: boolean): void; |
| |
| clearNodeSelection(): void; |
| |
| setDagPlaybackPlaying: (playing: boolean) => void; |
| |
| |
| |
| |
| |
| |
| setMeasureWidthPx(widthPx: number | null): void; |
| |
| setLayoutMode(mode: DagLayoutMode): void; |
| |
| |
| |
| |
| setLinearArcAdjacentGapPx(px: number, opts?: { skipRefit?: boolean }): void; |
| |
| |
| |
| |
| setDagCompactness(c: number): void; |
| |
| setEdgeTopPCoverage(coverage: number): void; |
| |
| |
| |
| |
| |
| setHideExcludedTokens(hide: boolean): void; |
| |
| hasPromptSpans(): boolean; |
| |
| detach(): void; |
| }; |
|
|
| function endpointNode( |
| ref: DagLink['source'] | DagLink['target'], |
| graph: DirectedGraph<DagNodeAttrs> |
| ): DagNode { |
| if (typeof ref === 'object' && ref !== null) return ref as DagNode; |
| const id = String(ref); |
| if (!graph.hasNode(id)) throw new Error(`genAttributeDagView: unknown node id ${id}`); |
| return graph.getNodeAttributes(id) as DagNode; |
| } |
|
|
| |
| function formatNodeOffsetRange(id: string): string { |
| const i = id.indexOf('_'); |
| if (i <= 0) return id; |
| const a = id.slice(0, i); |
| const b = id.slice(i + 1); |
| if (!/^\d+$/.test(a) || !/^\d+$/.test(b)) return id; |
| return `[${a}, ${b})`; |
| } |
|
|
| function buildNodeNativeTitleText( |
| d: Pick<DagNode, 'displayLabel' | 'id' | 'step'> & { targetProb?: number }, |
| ): string { |
| const lines = [ |
| d.displayLabel, |
| `Offset: ${formatNodeOffsetRange(d.id)}`, |
| `Step: ${d.step}`, |
| ]; |
| const { targetProb } = d; |
| if (targetProb !== undefined && Number.isFinite(targetProb)) { |
| lines.push(`Prob: ${formatTopkTooltipProbabilityPercent(targetProb)}`); |
| lines.push(`MI ratio: ${formatMutualInformationRatioForTooltip(computeMutualInformationRatio(targetProb))}`); |
| } |
| return lines.join('\n'); |
| } |
|
|
| |
| function buildLinkTitleText( |
| d: Pick<DagLink, 'normalizedScore' | 'mutualInformationRatio' | 'scoreShare' | 'alignmentNote'>, |
| src: DagNode, |
| tgt: DagNode |
| ): string { |
| const s = d.normalizedScore ?? 1; |
| const normStr = Number.isFinite(s) ? s.toFixed(3) : String(s); |
| const opacity = dagLinkStrokeOpacity(d); |
| const opacityStr = Number.isFinite(opacity) ? opacity.toFixed(3) : String(opacity); |
|
|
| const metrics = [ |
| `Attribution score: ${normStr}`, |
| `Target MI ratio: ${formatMutualInformationRatioForTooltip(d.mutualInformationRatio ?? 1)}`, |
| `Link strength: ${opacityStr}`, |
| ]; |
| const share = d.scoreShare; |
| if (typeof share === 'number' && Number.isFinite(share) && share > 0) { |
| metrics.push(`Fan in share: ${(share * 100).toFixed(1)}%`); |
| } |
| if (d.alignmentNote) { |
| metrics.push(d.alignmentNote); |
| } |
|
|
| return [ |
| `From:\n${src.displayLabel}\nOffset: ${formatNodeOffsetRange(src.id)}`, |
| `To:\n${tgt.displayLabel}\nOffset: ${formatNodeOffsetRange(tgt.id)}`, |
| metrics.join('\n'), |
| ].join('\n\n'); |
| } |
|
|
| |
| |
| |
| |
| const GLUE_EDGE_CHAR = /^(?:(?!\p{Script=Han})\p{L}|['\-_])$/u; |
|
|
| |
| |
| |
| |
| function snapSubwordNode(node: DagNode, prev: DagNode | null): void { |
| if (!prev || prev.end !== node.start || node.y !== prev.y) return; |
| const last = [...prev.label].at(-1) ?? ''; |
| const first = [...node.label][0] ?? ''; |
| if (!GLUE_EDGE_CHAR.test(last) || !GLUE_EDGE_CHAR.test(first)) return; |
| node.x = prev.x + prev.nodeW; |
| } |
|
|
| |
| function oneHopNeighborhood(graph: DirectedGraph<DagNodeAttrs>, nodeId: string): Set<string> { |
| const active = new Set<string>([nodeId]); |
| graph.forEachInNeighbor(nodeId, (n) => { |
| active.add(n); |
| }); |
| graph.forEachOutNeighbor(nodeId, (n) => { |
| active.add(n); |
| }); |
| return active; |
| } |
|
|
| |
| function dagLinkIncidentToFocus( |
| graph: DirectedGraph<DagNodeAttrs>, |
| focusId: string | null, |
| d: DagLink |
| ): boolean { |
| if (!focusId) return false; |
| const s = endpointNode(d.source, graph).id; |
| const t = endpointNode(d.target, graph).id; |
| return s === focusId || t === focusId; |
| } |
|
|
| |
| |
| |
| |
| function dagLinkHighlightStroke( |
| graph: DirectedGraph<DagNodeAttrs>, |
| focusId: string | null, |
| d: DagLink |
| ): string | null { |
| if (!focusId) return null; |
| const s = endpointNode(d.source, graph).id; |
| const t = endpointNode(d.target, graph).id; |
| if (s !== focusId && t !== focusId) return null; |
| if (s === focusId) return `var(${CSS_VAR_DAG_HIGHLIGHT_LINE_OUT})`; |
| return `var(${CSS_VAR_DAG_HIGHLIGHT_LINE_IN})`; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export type InitGenAttributeDagViewOptions = { |
| |
| onDagPlaybackToggle?: (playing: boolean) => void; |
| |
| onDagRefresh?: () => void; |
| |
| |
| |
| |
| dagCompactness?: number; |
| |
| |
| |
| displayScale?: number; |
| |
| |
| |
| |
| |
| measureWidthPx?: number; |
| |
| layoutMode?: DagLayoutMode; |
| |
| |
| |
| |
| linearArcAdjacentGapPx?: number; |
| |
| hideExcludedTokens?: boolean; |
| |
| edgeTopPCoverage?: number; |
| |
| onFullscreenError?: (message: string) => void; |
| }; |
|
|
| export function initGenAttributeDagView( |
| resultsRoot: D3Sel, |
| options?: InitGenAttributeDagViewOptions |
| ): GenAttributeDagHandle { |
| const onDagRefresh = options?.onDagRefresh; |
| const onDagPlaybackToggle = options?.onDagPlaybackToggle; |
| const onFullscreenError = options?.onFullscreenError; |
| let layoutMode: DagLayoutMode = options?.layoutMode ?? 'text-flow'; |
| let linearArcAdjacentGapPx = LINEAR_ARC_ADJACENT_GAP_DEFAULT; |
| if (options?.linearArcAdjacentGapPx !== undefined) { |
| const iv = options.linearArcAdjacentGapPx; |
| if (!Number.isFinite(iv)) { |
| throw new Error('genAttributeDagView: linearArcAdjacentGapPx must be finite'); |
| } |
| linearArcAdjacentGapPx = clampLinearArcAdjacentGap(iv); |
| } |
| let hideExcludedTokens: boolean = options?.hideExcludedTokens ?? false; |
| let edgeTopPCoverage = clampDagEdgeTopPCoverage( |
| options?.edgeTopPCoverage ?? DAG_EDGE_TOP_P_COVERAGE_DEFAULT, |
| ); |
|
|
| function reportFullscreenFailure(err: unknown): void { |
| if (!onFullscreenError) return; |
| const detail = |
| err instanceof Error |
| ? err.message |
| : typeof err === 'string' |
| ? err |
| : ''; |
| const base = tr('Fullscreen unavailable'); |
| onFullscreenError(detail ? `${base}: ${detail}` : base); |
| } |
|
|
| const rootEl = resultsRoot.node() as HTMLElement | null; |
| if (!rootEl) { |
| const noop = (): void => {}; |
| return { |
| setPromptTokenSpans: noop, |
| update: noop, |
| beginBatch: noop, |
| endBatch: noop, |
| isBatching: () => false, |
| reset: noop, |
| fitViewportToContent: noop, |
| clearNodeSelection: noop, |
| setDagPlaybackPlaying: noop, |
| setMeasureWidthPx: noop, |
| setLayoutMode: noop, |
| setLinearArcAdjacentGapPx: noop, |
| setDagCompactness: noop, |
| setEdgeTopPCoverage: noop, |
| setHideExcludedTokens: noop, |
| hasPromptSpans: () => false, |
| detach: noop, |
| }; |
| } |
|
|
| detachGenAttributeDagPanel.get(rootEl)?.(); |
| resultsRoot |
| .selectAll( |
| '.gen-attr-dag-stack, svg.gen-attr-dag-svg, button.gen-attr-dag-refresh, button.gen-attr-dag-play, button.gen-attr-dag-fullscreen' |
| ) |
| .remove(); |
|
|
| const stack = resultsRoot.append('div').attr('class', 'gen-attr-dag-stack'); |
| const stackEl = stack.node() as HTMLElement; |
|
|
| |
| function syncStackLayoutDragUi(): void { |
| stackEl.classList.toggle('gen-attr-dag-no-node-drag-layout', layoutMode !== 'text-flow'); |
| } |
| syncStackLayoutDragUi(); |
|
|
| if (options?.dagCompactness !== undefined && options?.displayScale !== undefined) { |
| throw new Error('genAttributeDagView: pass only one of dagCompactness or displayScale'); |
| } |
| const compactnessInit = options?.dagCompactness ?? options?.displayScale; |
| if (compactnessInit !== undefined) { |
| const c = clampDagCompactness(compactnessInit); |
| stackEl.style.setProperty(CSS_VAR_DAG_COMPACTNESS, String(c)); |
| } |
|
|
|
|
| const measureRoot = stack |
| .append('div') |
| .attr('class', 'gen-attr-dag-measure-layer') |
| .node() as HTMLElement; |
|
|
| function setMeasureWidthPx(widthPx: number | null): void { |
| if (widthPx === null) { |
| measureRoot.style.removeProperty('width'); |
| return; |
| } |
| if (!Number.isFinite(widthPx) || widthPx <= 0) { |
| throw new Error('genAttributeDagView: measureWidthPx must be a finite positive number'); |
| } |
| measureRoot.style.width = `${widthPx}px`; |
| } |
|
|
| if (options?.measureWidthPx !== undefined) { |
| setMeasureWidthPx(options.measureWidthPx); |
| } |
|
|
| const textMeasure = createGenAttributeDagTextMeasure(measureRoot); |
|
|
| |
| |
| |
| |
| let displayScale = readDisplayScaleFromCss(stackEl); |
| let linkEndInsetPx = linkEndInsetBaseAtUnitScalePx(measureRoot) * displayScale; |
|
|
| function refreshDagScaleDerivedFromCss(): void { |
| displayScale = readDisplayScaleFromCss(stackEl); |
| linkEndInsetPx = linkEndInsetBaseAtUnitScalePx(measureRoot) * displayScale; |
| } |
|
|
| function setDagCompactness(c: number): void { |
| const v = clampDagCompactness(c); |
| stackEl.style.setProperty(CSS_VAR_DAG_COMPACTNESS, String(v)); |
| refreshDagScaleDerivedFromCss(); |
| } |
|
|
| function setEdgeTopPCoverage(coverage: number): void { |
| edgeTopPCoverage = clampDagEdgeTopPCoverage(coverage); |
| } |
|
|
| const svg = stack.append('svg').attr('class', 'gen-attr-dag-svg'); |
|
|
| |
| const linkMarkersDefs = svg.append('defs').attr('class', 'gen-attr-dag-link-markers-defs'); |
|
|
| const rootG = svg.append('g').attr('class', 'gen-attr-dag-zoom-root'); |
|
|
| |
| |
| |
| |
| function initialDagZoomK(): number { |
| return 1 / displayScale; |
| } |
|
|
| function defaultDagZoomK(): number { |
| return initialDagZoomK() * dagInitialZoomBoost(layoutMode); |
| } |
|
|
| const zoomBehavior = d3 |
| .zoom<SVGSVGElement, unknown>() |
| .on('zoom', (event) => { |
| rootG.attr('transform', event.transform); |
| |
| |
| if (event.sourceEvent) layoutDirty = true; |
| }); |
|
|
| function applyInitialDagZoom(): void { |
| svg.call(zoomBehavior.transform, d3.zoomIdentity.scale(defaultDagZoomK())); |
| } |
|
|
| svg.call(zoomBehavior); |
| applyInitialDagZoom(); |
|
|
| svg.on('click', () => setSelectedNodeId(null)); |
|
|
| const linkG = rootG.append('g').attr('class', 'gen-attr-dag-links'); |
| const nodeG = rootG.append('g').attr('class', 'gen-attr-dag-nodes'); |
| |
| const linkGFront = rootG.append('g').attr('class', 'gen-attr-dag-links-front'); |
|
|
| const graph = new DirectedGraph<DagNodeAttrs>(); |
| let nodes: DagNode[] = []; |
| let links: DagLink[] = []; |
| let stepProcessed = 0; |
| let selectedId: string | null = null; |
| |
| let hoveredId: string | null = null; |
| |
| |
| |
| |
| |
| let dagExcludeIntervals: [number, number][] = []; |
| |
| |
| |
| |
| |
| |
| let layoutDirty = false; |
| |
| |
| |
| |
| |
| |
| let userDraggedNodes = false; |
|
|
| let linkSel = rootG |
| .selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link') |
| .data<DagLink>([], dagLinkDataKey); |
| let nodeSel = nodeG.selectAll<SVGGElement, DagNode>('g.gen-attr-dag-node').data<DagNode>([], (d) => d.id); |
|
|
| function syncSvgSize(): void { |
| const { w, h } = stackLayoutViewportPx(stackEl); |
| svg.attr('width', w).attr('height', h); |
| } |
|
|
| function paint(): void { |
| if (layoutMode === 'linear-arc') { |
| const layoutNodes = hideExcludedTokens |
| ? nodes.filter((n) => !isOffsetSpanFullyExcluded(n.start, n.end, dagExcludeIntervals)) |
| : nodes; |
| paintLinearArcLayout({ |
| linkSel, |
| nodeSel, |
| nodes: layoutNodes, |
| adjacentGapPx: linearArcAdjacentGapPx, |
| getLinkNodes: (d) => ({ |
| src: endpointNode(d.source, graph), |
| tgt: endpointNode(d.target, graph), |
| }), |
| }); |
| return; |
| } |
| if (layoutMode === 'spiral') { |
| const layoutNodes = hideExcludedTokens |
| ? nodes.filter((n) => !isOffsetSpanFullyExcluded(n.start, n.end, dagExcludeIntervals)) |
| : nodes; |
| paintSpiralLayout({ |
| linkSel, |
| nodeSel, |
| nodes: layoutNodes, |
| linkEndInsetPx, |
| getLinkNodes: (d) => ({ |
| src: endpointNode(d.source, graph), |
| tgt: endpointNode(d.target, graph), |
| }), |
| }); |
| return; |
| } |
| paintTextFlowLayout({ |
| linkSel, |
| nodeSel, |
| linkEndInsetPx, |
| getLinkNodes: (d) => ({ |
| src: endpointNode(d.source, graph), |
| tgt: endpointNode(d.target, graph), |
| }), |
| }); |
| } |
|
|
| const drag = d3 |
| .drag<SVGGElement, DagNode>() |
| |
| |
| .filter( |
| (event, d) => |
| !event.ctrlKey && |
| !event.button && |
| selectedId === d.id && |
| layoutMode === 'text-flow' |
| ) |
| .on('start', (event) => { |
| event.sourceEvent?.stopPropagation(); |
| }) |
| .on('drag', (event, d) => { |
| layoutDirty = true; |
| userDraggedNodes = true; |
| const [x, y] = d3.pointer(event, rootG.node()); |
| d.x = x; |
| d.y = y; |
| paint(); |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| function refreshNodeLinkHighlight(): void { |
| const focusId = hoveredId ?? selectedId; |
| const selectedNbhd = selectedId ? oneHopNeighborhood(graph, selectedId) : null; |
| const hoveredNbhd = hoveredId ? oneHopNeighborhood(graph, hoveredId) : null; |
|
|
| nodeSel |
| .classed('gen-attr-dag-node--hover', (d) => hoveredId === d.id) |
| .classed('gen-attr-dag-node--selected', (d) => selectedId === d.id) |
| .style('display', (d) => |
| hideExcludedTokens && isOffsetSpanFullyExcluded(d.start, d.end, dagExcludeIntervals) |
| ? 'none' : null |
| ) |
| .attr('opacity', (d) => { |
| if (selectedNbhd?.has(d.id)) return 1; |
| if (hoveredNbhd?.has(d.id)) return 1; |
| if (isOffsetSpanFullyExcluded(d.start, d.end, dagExcludeIntervals)) { |
| return hideExcludedTokens ? 0 : DAG_NODE_HIDDEN_OPACITY; |
| } |
| const hasGenTokens = nodes.some((n) => n.step >= 0); |
| const isPromptLeaf = hasGenTokens && d.step === -1 && graph.outDegree(d.id) === 0; |
| if (focusId || isPromptLeaf) return DAG_NODE_WEAKEN_OPACITY; |
| return 1; |
| }); |
| |
| |
| linkSel.each(function(d) { |
| const op = dagLinkStrokeOpacity(d); |
| const stroke = |
| dagLinkHighlightStroke(graph, focusId, d) ?? `var(${CSS_VAR_DAG_NORMAL_LINE_COLOR})`; |
| const g = d3.select(this); |
| g.select('path.gen-attr-dag-link-visible').attr('stroke', stroke).attr('stroke-opacity', op); |
| linkMarkersDefs |
| .select<SVGPathElement>(`#${dagLinkMarkerElementId(d.source, d.target)} path`) |
| .attr('stroke', stroke) |
| .attr('stroke-opacity', op); |
| }); |
| |
| |
| |
| |
| |
| rootG.selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link').each(function(d) { |
| const incident = dagLinkIncidentToFocus(graph, focusId, d); |
| const parent = incident ? linkGFront : linkG; |
| const parentNode = parent.node()!; |
| if (this.parentNode !== parentNode) { |
| parentNode.appendChild(this as SVGGElement); |
| } |
| }); |
| } |
|
|
| function setSelectedNodeId(id: string | null): void { |
| selectedId = id; |
| refreshNodeLinkHighlight(); |
| } |
|
|
| function clearNodeSelection(): void { |
| setSelectedNodeId(null); |
| } |
|
|
| |
| function syncGraphToSvg(): void { |
| linkGFront.selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link').each(function() { |
| linkG.node()!.appendChild(this as SVGGElement); |
| }); |
| linkMarkersDefs |
| .selectAll<SVGMarkerElement, DagLink>('marker') |
| .data(links, (d) => dagLinkMarkerElementId(d.source, d.target)) |
| .join((enter) => { |
| const m = enter |
| .append('marker') |
| .attr('id', (d) => dagLinkMarkerElementId(d.source, d.target)) |
| .attr('viewBox', `0 -${MARKER_HALF_H} ${MARKER_VW} ${MARKER_HALF_H * 2}`) |
| .attr('refX', MARKER_VW * 0.8) |
| .attr('refY', 0) |
| .attr('markerWidth', MARKER_SIZE) |
| .attr('markerHeight', MARKER_SIZE) |
| .attr('orient', 'auto'); |
| m.append('path') |
| .attr('d', `M0,-${MARKER_HALF_H} L${MARKER_VW},0 L0,${MARKER_HALF_H}`) |
| .attr('fill', 'none') |
| .attr('stroke', `var(${CSS_VAR_DAG_NORMAL_LINE_COLOR})`) |
| |
| .attr('stroke-width', MARKER_VW / MARKER_SIZE) |
| .attr('stroke-linecap', 'round') |
| .attr('stroke-linejoin', 'round'); |
| return m; |
| }); |
|
|
| linkSel = linkG |
| .selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link') |
| .data(links, dagLinkDataKey) |
| .join((enter) => { |
| const g = enter.append('g').attr('class', 'gen-attr-dag-link'); |
| g.each(function(d: DagLink) { |
| const el = d3.select(this); |
| const mkId = dagLinkMarkerElementId(d.source, d.target); |
| el.append('title').text(d.titleText); |
| el.append('path') |
| .attr('class', 'gen-attr-dag-link-visible') |
| .attr('fill', 'none') |
| .attr('stroke', `var(${CSS_VAR_DAG_NORMAL_LINE_COLOR})`) |
| .attr('stroke-width', `var(${CSS_VAR_DAG_LINK_STROKE_WIDTH})`) |
| .attr('pointer-events', 'stroke') |
| .attr('marker-end', `url(#${mkId})`); |
| }); |
| return g; |
| }); |
| |
| |
|
|
| nodeSel = nodeG |
| .selectAll<SVGGElement, DagNode>('g.gen-attr-dag-node') |
| .data(nodes, (d) => d.id) |
| .join( |
| (enter) => { |
| |
| |
| |
| const g = enter.append('g').attr('class', 'gen-attr-dag-node'); |
| g.classed('gen-attr-dag-node--prompt', (d: DagNode) => d.step === -1); |
| if (!DISABLE_DAG_NODE_TOOLTIPS) { |
| g.append('title').text((d: DagNode) => d.nativeTitleText); |
| } |
| g.append('rect') |
| .attr('x', 0) |
| .attr('y', 0) |
| .attr('width', (d: DagNode) => d.nodeW) |
| .attr('height', (d: DagNode) => d.nodeH) |
| .attr('rx', (d: DagNode) => nodeRx(d)) |
| .attr('ry', (d: DagNode) => nodeRx(d)); |
| g.append('text') |
| .attr('class', 'gen-attr-dag-node-text') |
| .attr('xml:space', 'preserve') |
| .attr('pointer-events', 'none') |
| .attr('text-anchor', 'middle') |
| .attr('dominant-baseline', 'central') |
| .attr('x', (d: DagNode) => d.nodeW / 2) |
| .attr('y', (d: DagNode) => d.nodeH / 2) |
| .text((d: DagNode) => d.displayLabel); |
| g.on('mouseenter', (_event, d) => { |
| hoveredId = d.id; |
| refreshNodeLinkHighlight(); |
| }); |
| g.on('mouseleave', () => { |
| hoveredId = null; |
| refreshNodeLinkHighlight(); |
| }); |
| g.on('click', (event, d) => { |
| event.stopPropagation(); |
| setSelectedNodeId(selectedId === d.id ? null : d.id); |
| }); |
| return g.call(drag); |
| } |
| ); |
|
|
| paint(); |
| refreshNodeLinkHighlight(); |
| } |
|
|
| |
| |
| |
| |
| |
| let batchDepth = 0; |
| function beginBatch(): void { |
| batchDepth++; |
| } |
| function endBatch(): void { |
| if (batchDepth === 0) return; |
| batchDepth--; |
| if (batchDepth === 0) { |
| syncGraphToSvg(); |
| |
| |
| |
| } |
| } |
|
|
| function isBatching(): boolean { |
| return batchDepth > 0; |
| } |
|
|
| function setPromptTokenSpans(spans: PromptTokenSpan[], promptText: string): void { |
| const geomByKey = textMeasure.setPrompt(promptText, spans); |
| const addedNodes: DagNode[] = []; |
| for (const attr of spans) { |
| const [ns, ne] = attr.offset; |
| const srcId = `${ns}_${ne}`; |
| if (graph.hasNode(srcId)) continue; |
| const g = geomByKey.get(srcId); |
| if (!g) { |
| throw new Error(`genAttributeDagView: missing layout for prompt node ${srcId}`); |
| } |
| const displayLabel = visualizeSpecialChars(attr.raw, { |
| spaceDotExceptBeforeAsciiLetterOrNumber: true, |
| }); |
| const srcNode: DagNode = { |
| id: srcId, |
| label: attr.raw, |
| step: -1, |
| start: ns, |
| end: ne, |
| x: g.originX, |
| y: g.originY, |
| nodeW: g.width * displayScale, |
| nodeH: g.height * displayScale, |
| displayLabel, |
| nativeTitleText: buildNodeNativeTitleText({ |
| displayLabel, |
| id: srcId, |
| step: -1, |
| }), |
| }; |
| graph.addNode(srcId, srcNode); |
| nodes.push(srcNode); |
| addedNodes.push(srcNode); |
| } |
| const firstNewIdx = nodes.length - addedNodes.length; |
| for (let i = 0; i < addedNodes.length; i++) { |
| const prevIdx = firstNewIdx + i - 1; |
| snapSubwordNode(addedNodes[i]!, prevIdx >= 0 ? nodes[prevIdx]! : null); |
| } |
| dagExcludeIntervals = collectGenAttrDagExcludeIntervals(promptText, promptText.length); |
| if (batchDepth === 0) syncGraphToSvg(); |
| } |
|
|
| |
| function nodeIntervalsForAlign(): NodeInterval[] { |
| return nodes.map((n) => ({ id: n.id, start: n.start, end: n.end, label: n.label })); |
| } |
|
|
| function update(step: TokenGenStep, excludeIntervalContext?: string): void { |
| const { context, token, response } = step; |
| if (!response.token_attribution || !token) return; |
|
|
| const intervalCtx = excludeIntervalContext ?? step.context; |
|
|
| const targetStart = context.length; |
| const targetEnd = context.length + token.length; |
| const targetId = `${targetStart}_${targetEnd}`; |
| if (graph.hasNode(targetId)) { |
| throw new Error( |
| `genAttributeDagView: unexpected duplicate target node id=${targetId} at stepProcessed=${stepProcessed} (same update() or out-of-order replay?)` |
| ); |
| } |
| const g = textMeasure.appendGeneratedToken(token, [targetStart, targetEnd]); |
| const displayLabel = visualizeSpecialChars(token, { |
| spaceDotExceptBeforeAsciiLetterOrNumber: true, |
| }); |
| const targetNode: DagNode = { |
| id: targetId, |
| label: token, |
| step: stepProcessed, |
| start: targetStart, |
| end: targetEnd, |
| x: g.originX, |
| y: g.originY, |
| nodeW: g.width * displayScale, |
| nodeH: g.height * displayScale, |
| displayLabel, |
| nativeTitleText: buildNodeNativeTitleText({ |
| displayLabel, |
| id: targetId, |
| step: stepProcessed, |
| targetProb: response.target_prob, |
| }), |
| }; |
| graph.addNode(targetId, targetNode); |
| nodes.push(targetNode); |
| snapSubwordNode(targetNode, nodes.length >= 2 ? nodes[nodes.length - 2]! : null); |
|
|
| |
| const pieces: PieceEntry[] = (response.token_attribution ?? []).map((t) => ({ |
| offset: t.offset as [number, number], |
| raw: t.raw, |
| score: t.score, |
| })); |
| const aggregated = alignAndAggregateByNode(pieces, nodeIntervalsForAlign(), { |
| step: stepProcessed, |
| targetToken: token, |
| }); |
| const afterExclude = excludeNodeAggregatedEntries(step, aggregated, excludeIntervalContext); |
| const selected = phase2RankAndSparsify(afterExclude, { cumulativeShare: edgeTopPCoverage }); |
|
|
| const mutualInformationRatio = computeMutualInformationRatio(response.target_prob); |
| |
| const selectedForDisplay = selected.filter( |
| (item) => |
| dagLinkStrokeOpacity({ |
| normalizedScore: item.score, |
| mutualInformationRatio, |
| }) >= DAG_EDGE_MIN_DISPLAY_OPACITY |
| ); |
| const massSum = selectedForDisplay.reduce((acc, t) => acc + Math.max(0, t.poolMassFrac), 0); |
| for (const item of selectedForDisplay) { |
| const srcId = item.nodeId; |
| if (!graph.hasNode(srcId)) { |
| throw new Error( |
| `genAttributeDagView: attribution nodeId ${srcId} has no graph node at stepProcessed=${stepProcessed} (align/DAG out of sync)` |
| ); |
| } |
| const share = massSum > 0 ? item.poolMassFrac / massSum : undefined; |
| const alignmentNote = |
| item.alignmentTooltipLines && item.alignmentTooltipLines.length > 0 |
| ? item.alignmentTooltipLines.join('\n\n') |
| : undefined; |
| if (graph.hasEdge(srcId, targetId)) { |
| throw new Error( |
| `genAttributeDagView: unexpected duplicate edge ${srcId} -> ${targetId} at stepProcessed=${stepProcessed} (duplicate nodeId in selected or repeat update?)` |
| ); |
| } |
| const edgeAttrs = { |
| normalizedScore: item.score, |
| mutualInformationRatio, |
| scoreShare: share, |
| ...(alignmentNote ? { alignmentNote } : {}), |
| }; |
| graph.addEdge(srcId, targetId, edgeAttrs); |
| const srcAttrs = graph.getNodeAttributes(srcId) as DagNode; |
| const tgtAttrs = graph.getNodeAttributes(targetId) as DagNode; |
| links.push({ |
| source: srcId, |
| target: targetId, |
| ...edgeAttrs, |
| titleText: buildLinkTitleText(edgeAttrs, srcAttrs, tgtAttrs), |
| }); |
| } |
|
|
| const excludeIntervals = collectGenAttrDagExcludeIntervals(intervalCtx, step.promptRegionEnd); |
| dagExcludeIntervals = excludeIntervals; |
| pruneDagLinksTouchingFullyExcludedNodes(graph, links, excludeIntervals); |
|
|
| stepProcessed++; |
| |
| selectedId = targetId; |
| if (batchDepth === 0) { |
| syncGraphToSvg(); |
| |
| |
| fitViewportToContent(); |
| } |
| } |
|
|
| function reset(preserveUserViewport: boolean = false): void { |
| const wasLayoutDirty = layoutDirty; |
| clearGenAttributeDagAlignmentWarnDedupe(); |
| textMeasure.reset(); |
| graph.clear(); |
| nodes = []; |
| links = []; |
| stepProcessed = 0; |
| selectedId = null; |
| hoveredId = null; |
| linkMarkersDefs.selectAll('marker').remove(); |
| linkG.selectAll('*').remove(); |
| linkGFront.selectAll('*').remove(); |
| nodeG.selectAll('*').remove(); |
| dagExcludeIntervals = []; |
| linkSel = rootG |
| .selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link') |
| .data<DagLink>([], dagLinkDataKey); |
| nodeSel = nodeG.selectAll<SVGGElement, DagNode>('g.gen-attr-dag-node').data<DagNode>([], (d) => d.id); |
| layoutDirty = preserveUserViewport ? wasLayoutDirty : false; |
| userDraggedNodes = false; |
| } |
|
|
| function fitViewportToContent(force: boolean = false): void { |
| syncSvgSize(); |
| if (layoutDirty && !force) { |
| return; |
| } |
| const k0 = defaultDagZoomK(); |
| if (nodes.length === 0) { |
| applyInitialDagZoom(); |
| } else { |
| svg.call(zoomBehavior.transform, d3.zoomIdentity); |
| const pad = 12; |
| const { w, h } = stackLayoutViewportPx(stackEl); |
| const innerW = Math.max(w - 2 * pad, 1); |
| const innerH = Math.max(h - 2 * pad, 1); |
| if (layoutMode === 'linear-arc') { |
| |
| const bn = nodeG.node()!.getBBox(); |
| const bw = Math.max(bn.width, 1e-6); |
| const kRaw = innerW / bw; |
| const k = Math.min(Number.isFinite(kRaw) && kRaw > 0 ? kRaw : k0, k0); |
| const tx = pad * 2 - k * bn.x; |
| const rowMidY = bn.y + bn.height / 2; |
| const ty = pad + innerH / 2 - k * rowMidY; |
| svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(k)); |
| } else if (layoutMode === 'spiral') { |
| |
| |
| |
| |
| const b = rootG.node()!.getBBox(); |
| const xmin = b.x; |
| const xmax = b.x + b.width; |
| const ymin = b.y; |
| const ymax = b.y + b.height; |
| const halfW = innerW / 2; |
| const halfH = innerH / 2; |
| let kFromOrigin = Infinity; |
| if (xmax > 0) kFromOrigin = Math.min(kFromOrigin, halfW / xmax); |
| if (xmin < 0) kFromOrigin = Math.min(kFromOrigin, halfW / (-xmin)); |
| if (ymax > 0) kFromOrigin = Math.min(kFromOrigin, halfH / ymax); |
| if (ymin < 0) kFromOrigin = Math.min(kFromOrigin, halfH / (-ymin)); |
| const bw = Math.max(b.width, 1e-6); |
| const bh = Math.max(b.height, 1e-6); |
| const kFromSides = Math.min(innerW / bw, innerH / bh); |
| const kRaw = Number.isFinite(kFromOrigin) && kFromOrigin > 0 ? kFromOrigin : kFromSides; |
| const k = Math.min(kRaw, k0); |
| const tx = pad + halfW; |
| const ty = pad + halfH; |
| svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(k)); |
| } else if (layoutMode === 'text-flow') { |
| |
| const b = rootG.node()!.getBBox(); |
| const bw = Math.max(b.width, 1e-6); |
| const bh = Math.max(b.height, 1e-6); |
| const kRaw = Math.min(innerW / bw, innerH / bh); |
| const k = Math.min(Number.isFinite(kRaw) && kRaw > 0 ? kRaw : k0, k0); |
| const tx = pad * 2 - k * b.x; |
| const ty = pad - k * b.y; |
| svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(k)); |
| } else { |
| const _: never = layoutMode; |
| throw new Error(`genAttributeDagView: unsupported layoutMode for fit (${String(_)})`); |
| } |
| } |
| |
| layoutDirty = false; |
| userDraggedNodes = false; |
| } |
|
|
| |
| |
| |
| |
| |
| const ro = new ResizeObserver(() => { |
| if (batchDepth > 0) return; |
| |
| |
| if (nodes.length > 0) { |
| fitViewportToContent(); |
| } else { |
| syncSvgSize(); |
| } |
| }); |
| ro.observe(stackEl); |
|
|
| let dagPlaybackPlaying = false; |
| const playBtn = resultsRoot |
| .append('button') |
| .attr('type', 'button') |
| .attr('class', 'refresh-btn gen-attr-dag-play') |
| .attr('title', 'Play') |
| .text('▶') |
| .style('display', onDagPlaybackToggle ? null : 'none') |
| .on('click', (event) => { |
| event.stopPropagation(); |
| if (!onDagPlaybackToggle) return; |
| onDagPlaybackToggle(!dagPlaybackPlaying); |
| }); |
|
|
| function setDagPlaybackPlaying(playing: boolean): void { |
| dagPlaybackPlaying = playing; |
| playBtn.text(playing ? '⏸' : '▶').attr('title', playing ? 'Pause' : 'Play'); |
| } |
|
|
| function setLayoutMode(mode: DagLayoutMode): void { |
| if (layoutMode === mode) return; |
| layoutMode = mode; |
| syncStackLayoutDragUi(); |
| if (batchDepth > 0) return; |
| syncGraphToSvg(); |
| fitViewportToContent(true); |
| } |
|
|
| function setLinearArcAdjacentGapPx(px: number, opts?: { skipRefit?: boolean }): void { |
| if (!Number.isFinite(px)) { |
| throw new Error('genAttributeDagView: linear arc adjacent node gap must be finite'); |
| } |
| const next = clampLinearArcAdjacentGap(px); |
| if (linearArcAdjacentGapPx === next) return; |
| linearArcAdjacentGapPx = next; |
| if (opts?.skipRefit || batchDepth > 0) return; |
| if (layoutMode !== 'linear-arc' || nodes.length === 0) return; |
| paint(); |
| fitViewportToContent(true); |
| } |
|
|
| function setHideExcludedTokens(hide: boolean): void { |
| if (hideExcludedTokens === hide) return; |
| hideExcludedTokens = hide; |
| if (batchDepth > 0 || nodes.length === 0) return; |
| paint(); |
| refreshNodeLinkHighlight(); |
| fitViewportToContent(true); |
| } |
|
|
| const fullscreenBtn = resultsRoot |
| .append('button') |
| .attr('type', 'button') |
| .attr('class', 'refresh-btn gen-attr-dag-fullscreen') |
| .attr('title', 'Fullscreen') |
| .text('⛶'); |
|
|
| |
|
|
| function updateFullscreenBtnIcon(): void { |
| const active = dagResultsSurfaceFullscreenExpanded(rootEl); |
| fullscreenBtn.text(active ? '×' : '⛶').attr('title', active ? 'Exit fullscreen' : 'Fullscreen'); |
| } |
|
|
| function refreshFullscreenChrome(): void { |
| updateFullscreenBtnIcon(); |
| syncSvgSize(); |
| } |
|
|
| fullscreenBtn.on('click', (event) => { |
| event.stopPropagation(); |
| void (async (): Promise<void> => { |
| await runDagFullscreenToggleWithPseudoWorkaround({ |
| rootEl, |
| onNativeExitFailure: reportFullscreenFailure, |
| }); |
| refreshFullscreenChrome(); |
| })(); |
| }); |
|
|
| |
| document.addEventListener('fullscreenchange', refreshFullscreenChrome); |
| document.addEventListener(CSS_PSEUDO_FULLSCREEN_CHANGE_EVENT, refreshFullscreenChrome); |
|
|
| resultsRoot |
| .append('button') |
| .attr('type', 'button') |
| .attr('class', 'refresh-btn gen-attr-dag-refresh') |
| .attr('title', 'Refresh') |
| .text('↻') |
| .on('click', (event) => { |
| event.stopPropagation(); |
| |
| |
| |
| |
| |
| |
| const wasDirty = layoutDirty; |
| const wasNodeDragged = userDraggedNodes; |
| const shouldFit = !wasDirty || !wasNodeDragged; |
| reset(); |
| onDagRefresh?.(); |
| if (shouldFit) { |
| fitViewportToContent(true); |
| } else { |
| layoutDirty = true; |
| } |
| |
| clearNodeSelection(); |
| }); |
|
|
| syncSvgSize(); |
|
|
| function detach(): void { |
| detachDagPseudoFullscreenIfPresent(rootEl); |
| ro.disconnect(); |
| document.removeEventListener('fullscreenchange', refreshFullscreenChrome); |
| document.removeEventListener(CSS_PSEUDO_FULLSCREEN_CHANGE_EVENT, refreshFullscreenChrome); |
| resultsRoot |
| .selectAll( |
| '.gen-attr-dag-stack, button.gen-attr-dag-refresh, button.gen-attr-dag-play, button.gen-attr-dag-fullscreen' |
| ) |
| .remove(); |
| detachGenAttributeDagPanel.delete(rootEl); |
| } |
|
|
| detachGenAttributeDagPanel.set(rootEl, detach); |
|
|
| return { |
| setPromptTokenSpans, |
| update, |
| beginBatch, |
| endBatch, |
| isBatching, |
| reset, |
| fitViewportToContent, |
| clearNodeSelection, |
| setDagPlaybackPlaying, |
| setMeasureWidthPx, |
| setLayoutMode, |
| setLinearArcAdjacentGapPx, |
| setDagCompactness, |
| setEdgeTopPCoverage, |
| setHideExcludedTokens, |
| hasPromptSpans: () => nodes.some((n) => n.step === -1), |
| detach, |
| }; |
| } |
|
|