coltrace 0.1.0.dev4__py3-none-any.whl

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.
@@ -0,0 +1,418 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{{ selected_name|e if selected_name else 'Project' }} · ColTrace</title>
7
+ <style>{% include 'lib/vis-9.1.2/vis-network.css' %}</style>
8
+ <style>
9
+ :root{color-scheme:light;--ink:#172d37;--muted:#617580;--line:#dce5e8;--teal:#087f8c;
10
+ --surface:#fff;--canvas:#f5f8f9;--source:#2563eb;--transform:#7c3aed;--use:#168366;--boundary:#b66a13}
11
+ *{box-sizing:border-box}body{margin:0;font:14px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
12
+ color:var(--ink);background:var(--canvas)}button{font:inherit}button:focus-visible,summary:focus-visible{
13
+ outline:3px solid #61b9c4;outline-offset:3px}button{cursor:pointer}button:disabled{cursor:default;opacity:.45}
14
+ .app{height:100vh;height:100dvh;display:grid;grid-template-rows:auto minmax(0,1fr);overflow:hidden}
15
+ .app-header{min-height:80px;padding:14px 24px;display:flex;align-items:center;gap:20px;background:white;
16
+ border-bottom:1px solid var(--line);z-index:2}.brand{display:flex;align-items:center;gap:10px;font-size:17px;
17
+ font-weight:750;letter-spacing:-.5px;flex-shrink:0}.brand-mark{display:grid;place-items:center;width:33px;
18
+ height:36px;border-radius:10px;background:var(--teal);color:white;font-size:21px;font-weight:600}
19
+ .header-divider{width:1px;height:34px;background:var(--line)}.column-heading{min-width:0;flex:1}
20
+ .column-heading h1{margin:0;font-size:19px;line-height:1.3;letter-spacing:-.3px;overflow-wrap:anywhere}
21
+ .column-heading p{margin:3px 0 0;color:var(--muted);font-size:12px;overflow-wrap:anywhere}
22
+ .static-badge{padding:6px 10px;border:1px solid var(--line);border-radius:7px;font-size:12px;color:#526a75;
23
+ white-space:nowrap;background:#f8fafb}.static-badge:before{content:"";display:inline-block;width:6px;height:6px;
24
+ margin:0 7px 1px 0;border-radius:50%;background:var(--teal)}
25
+ .workspace{display:grid;grid-template-columns:minmax(0,1fr) clamp(380px,36vw,540px);min-height:0}
26
+ .graph-pane{position:relative;min-height:0;min-width:0;overflow:hidden;background-color:var(--canvas);
27
+ background-image:radial-gradient(#d1dce0 .8px,transparent .8px);background-size:20px 20px}
28
+ #mynetwork{width:100%;height:100%;min-height:360px;outline:none}.graph-overview{position:absolute;top:20px;
29
+ left:22px;right:20px;z-index:1;pointer-events:none;display:flex;justify-content:space-between;align-items:flex-start;
30
+ gap:16px}.graph-overview h2{font-size:13px;margin:0 0 4px;font-weight:700}.graph-overview p{font-size:12px;
31
+ color:var(--muted);margin:0;max-width:310px}.legend{display:flex;flex-wrap:wrap;gap:6px 12px;max-width:380px;
32
+ font-size:11px;color:#59717d;justify-content:flex-end}.legend span{white-space:nowrap}.legend i{display:inline-block;
33
+ width:8px;height:8px;margin-right:4px;border-radius:3px}.legend .source{background:#bcd2ff}.legend .transform{
34
+ background:#d9ccfc}.legend .use{background:#9fddc8}.legend .boundary{background:#f3d49d}
35
+ .legend .frame{background:#bdcdd8}
36
+ .graph-toolbar{position:absolute;left:50%;bottom:24px;transform:translateX(-50%);display:flex;align-items:center;
37
+ gap:3px;padding:6px;background:white;border:1px solid #d4e0e4;border-radius:12px;
38
+ box-shadow:0 4px 18px #203e4b0c;z-index:1;white-space:nowrap}.graph-toolbar button{height:34px;min-width:34px;
39
+ padding:0 10px;border:0;background:transparent;border-radius:7px;font-size:12px;font-weight:600;color:#405d69}
40
+ .graph-toolbar button:hover{background:#edf5f6;color:var(--teal)}.graph-toolbar .zoom-symbol{font-size:21px;
41
+ font-weight:400;padding:0 5px}.toolbar-divider{height:21px;width:1px;background:var(--line);margin:0 5px}
42
+ #zoom-level{min-width:44px;text-align:center;color:#627984;font-size:11px;font-variant-numeric:tabular-nums}
43
+ .graph-hint{position:absolute;bottom:78px;left:0;right:0;text-align:center;color:#798d96;font-size:11px;
44
+ pointer-events:none}.sidebar{min-height:0;display:flex;flex-direction:column;background:white;
45
+ border-left:1px solid var(--line);overflow:hidden}.sidebar-heading{padding:20px 22px 16px}
46
+ .eyebrow{font-size:10px;font-weight:750;letter-spacing:1.2px;text-transform:uppercase;color:var(--teal);margin:0 0 6px}
47
+ .sidebar-heading h2{margin:0;font-size:18px;letter-spacing:-.3px}.sidebar-heading p:last-child{color:var(--muted);
48
+ font-size:12px;margin:7px 0 0;line-height:1.6}.sidebar-tabs{display:flex;gap:18px;padding:0 22px;
49
+ border-bottom:1px solid var(--line);flex-shrink:0}.sidebar-tabs button{position:relative;padding:10px 0 12px;
50
+ border:0;background:transparent;font-size:12px;font-weight:650;color:#71838c}.sidebar-tabs button[aria-selected="true"]{
51
+ color:var(--teal)}.sidebar-tabs button[aria-selected="true"]:after{content:"";position:absolute;height:3px;
52
+ left:0;right:0;bottom:-1px;border-radius:3px 3px 0 0;background:var(--teal)}.tab-count{font-weight:500;
53
+ font-size:10px;line-height:1;background:#eef3f5;padding:3px 5px;border-radius:4px;margin-left:4px}
54
+ .sidebar-panel{position:relative;padding:20px 22px;min-height:0;overflow:auto;overscroll-behavior:contain;flex:1}
55
+ .sidebar-panel[hidden]{display:none}.history-summary{display:flex;align-items:center;justify-content:space-between;
56
+ gap:12px;margin-bottom:16px;font-size:11px;color:var(--muted)}#active-step{font-weight:600;color:var(--teal)}
57
+ .history-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:16px}
58
+ .stage{position:relative;border:1px solid var(--line);border-radius:10px;background:white;overflow:hidden;
59
+ border-left:3px solid #c8d5dc;transition:border-color .15s,box-shadow .15s}.stage.source{border-left-color:var(--source)}
60
+ .stage.transform{border-left-color:var(--transform)}.stage.use{border-left-color:var(--use)}
61
+ .stage.boundary,.stage.unverified{border-left-color:var(--boundary);background:#fffdfa}.stage.unverified{border-style:dashed}
62
+ .stage.frame{border-left-color:#7997ab}.stage.derivation{border-left-color:#a372c7}
63
+ .stage.control,.stage.configuration,.stage.context{border-left-color:#a8b3bb;background:#fafbfc}
64
+ .stage.is-active{border-color:var(--teal);
65
+ box-shadow:0 0 0 2px #087f8c16}.stage-header{display:flex;align-items:center;justify-content:space-between;
66
+ gap:8px;padding:12px 14px 0}.stage-number{font-size:10px;font-weight:700;letter-spacing:.7px;color:#7c8e97}
67
+ .category-chip{font-size:10px;line-height:1.4;padding:3px 7px;border-radius:4px;color:#5f5287;background:#f2edfc}
68
+ .source .category-chip{color:#3663ac;background:#eef4ff}.use .category-chip{color:#26745c;background:#edf8f3}
69
+ .boundary .category-chip,.unverified .category-chip{color:#9a621d;background:#fff2dc}.stage h3{font-size:13px;line-height:1.6;margin:8px 14px;
70
+ font-weight:650;overflow-wrap:anywhere}.focus-stage{display:block;width:100%;text-align:left;border:0;
71
+ background:transparent;padding:0;color:inherit;line-height:inherit;font-weight:inherit}.focus-stage:hover{
72
+ color:var(--teal)}.stage-location{font-size:11px;line-height:1.7;color:#71838c;margin:0 14px 11px;
73
+ overflow-wrap:anywhere}.stage-location strong{font-weight:550;color:#4f6874}.stage-code{margin:0 12px 12px;
74
+ padding:11px 12px;background:#f4f7f8;border:1px solid #eaf0f2;border-radius:6px;white-space:pre-wrap;
75
+ overflow-wrap:anywhere;tab-size:4;font:12px/1.7 ui-monospace,SFMono-Regular,Consolas,monospace;color:#344f5c}
76
+ .column-flow{margin:0 14px 12px;font-size:11px;color:#526b77;line-height:1.7;overflow-wrap:anywhere}
77
+ .evidence{margin:0 14px 12px;display:flex;flex-wrap:wrap;gap:5px;font-size:10.5px;color:#788b95}
78
+ .evidence span{padding:2px 5px;background:#f1f5f6;border-radius:3px}.boundary .evidence span,.unverified .evidence span{
79
+ background:#fff2df;color:#9b692c}.history-footnote{font-size:11px;line-height:1.7;color:#7a8d96;margin:20px 0 0}
80
+ .notice{padding:11px 12px;border:1px solid #f2d49f;background:#fff8e9;border-radius:7px;color:#88641e;
81
+ font-size:12px;line-height:1.6;margin:0 0 16px}.panel-title{font-size:13px;font-weight:650;margin:0 0 9px}
82
+ .panel-description{font-size:12px;color:var(--muted);margin:0 0 18px;line-height:1.7}
83
+ #details{font:12px/1.8 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;
84
+ overflow-wrap:anywhere;margin:0;color:#405b68}.diagnostics-list{list-style:none;padding:0;margin:0;
85
+ display:flex;flex-direction:column;gap:12px}.diagnostic{border:1px solid var(--line);border-radius:8px;padding:13px 14px;
86
+ font-size:12px;line-height:1.6;overflow-wrap:anywhere}.diagnostic header{display:flex;align-items:center;
87
+ justify-content:space-between;gap:10px;margin-bottom:8px}.diagnostic strong{font-size:11px}
88
+ .diagnostic p{margin:7px 0 0}.diagnostic small{color:var(--muted);font-size:10px}.diagnostic .hint{color:#526f7b}
89
+ .empty-state{font-size:12px;color:var(--muted);line-height:1.7;margin:0}
90
+ .stage-explanation{margin:0 14px 12px;font-size:11px;color:#617580;line-height:1.7}
91
+ .fate-card,.outcome-card,.frame-card{border:1px solid var(--line);border-radius:9px;padding:14px;margin:0 0 13px;
92
+ overflow-wrap:anywhere;background:#fafcfc}.fate-card{border-left:3px solid var(--teal);background:#f1f8f7}
93
+ .fate-card.unverified_export,.fate-card.boundary{border-left-color:var(--boundary);background:#fff8e9}
94
+ .fate-card h3,.outcome-card h4,.frame-card h4{font-size:13px;margin:0 0 6px;font-weight:650}
95
+ .fate-card p,.outcome-card p,.frame-card p{font-size:12px;margin:6px 0;color:#526b77;line-height:1.7}
96
+ .outcome-card code,.frame-card code{font-size:10px;color:#758a96;overflow-wrap:anywhere}
97
+ .outcome-heading{display:flex;justify-content:space-between;align-items:center;gap:8px}
98
+ .outcome-meta{font-size:10px;background:#eef3f5;border-radius:4px;padding:2px 6px;color:#607681}
99
+ .outcome-section{margin:22px 0 0}.outcome-section h3{font-size:13px;margin:0 0 10px}
100
+ .creation-details{margin-top:22px;border-top:1px solid var(--line);padding-top:16px}
101
+ .creation-details summary{cursor:pointer;font-weight:650;font-size:12px;color:#526b77;line-height:1.7}
102
+ .creation-details .history-list{margin-top:14px}.creation-details h3{font-weight:600}
103
+ .creation-details .stage-number{letter-spacing:.3px}.binding-list{list-style:none;padding:0;margin:6px 0}
104
+ .binding-list li{font-size:11px;color:#526b77;line-height:1.8}.outcome-fate{border-top:1px solid var(--line);padding-top:8px}
105
+ @media(max-width:1100px){.legend{max-width:200px}.graph-overview p{max-width:240px}.app-header{padding:13px 18px}}
106
+ @media(max-width:960px){.app{height:auto;min-height:100dvh;overflow:visible}.app-header{min-height:76px}
107
+ .workspace{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(440px,58dvh) minmax(400px,64dvh)}
108
+ .sidebar{border-left:0;border-top:1px solid var(--line)}#mynetwork{min-height:0}.sidebar-heading{padding:17px 22px 12px}
109
+ .graph-overview{top:16px;left:18px}.graph-toolbar{bottom:20px}.graph-hint{bottom:72px}}
110
+ @media(max-width:560px){.brand{font-size:0;gap:0}.brand-mark{font-size:20px}.app-header{gap:12px;padding:12px 14px}
111
+ .header-divider,.static-badge{display:none}.column-heading h1{font-size:16px}.column-heading p{font-size:10px}
112
+ .legend{max-width:155px;font-size:10px;gap:5px 8px}.graph-overview p{max-width:165px;font-size:10px}
113
+ .sidebar-heading,.sidebar-panel{padding-left:16px;padding-right:16px}.sidebar-tabs{padding:0 16px}
114
+ .workspace{grid-template-rows:minmax(400px,56dvh) minmax(440px,68dvh)}}
115
+ </style>
116
+ <script>{% include 'lib/vis-9.1.2/vis-network.min.js' %}</script>
117
+ </head>
118
+ <body>
119
+ <div class="app">
120
+ <header class="app-header">
121
+ <div class="brand"><span class="brand-mark" aria-hidden="true">C</span>ColTrace</div>
122
+ <span class="header-divider" aria-hidden="true"></span>
123
+ <div class="column-heading"><h1>{{ selected_name|e if selected_name else 'Project lineage' }}</h1>
124
+ <p>{{ selected_scope|e if selected_scope else 'Explore identified column dependencies' }}</p></div>
125
+ <span class="static-badge">Static analysis · {{ tool_version|e }}</span>
126
+ </header>
127
+ <main class="workspace">
128
+ <section class="graph-pane" aria-label="Graph workspace">
129
+ <div class="graph-overview"><div><h2>{% if history_mode %}Column journey{% else %}Column dependencies{% endif %}</h2>
130
+ <p>Click a block to inspect its source. Drag to explore.</p></div>
131
+ <div class="legend" aria-label="Stage categories">
132
+ <span><i class="source"></i>Origin</span><span><i class="transform"></i>Write / derive</span>
133
+ <span><i class="use"></i>Read / use</span><span><i class="frame"></i>Frame passage</span>
134
+ <span><i class="boundary"></i>Unresolved</span>
135
+ </div>
136
+ </div>
137
+ <div id="mynetwork" role="img" aria-label="Interactive directed lineage graph"></div>
138
+ <p class="graph-hint">Scroll to zoom · Drag to pan · Selection outlines: blue Upstream, green Downstream</p>
139
+ <div class="graph-toolbar" role="toolbar" aria-label="Graph navigation">
140
+ <button id="zoom-out" class="zoom-symbol" type="button" aria-label="Zoom out" title="Zoom out">−</button>
141
+ <output id="zoom-level" aria-label="Zoom level">85%</output>
142
+ <button id="zoom-in" class="zoom-symbol" type="button" aria-label="Zoom in" title="Zoom in">+</button>
143
+ <span class="toolbar-divider" aria-hidden="true"></span>
144
+ <button id="fit" type="button" title="Show the complete graph">Fit all</button>
145
+ <button id="reset" type="button" title="Return to the selected column at a readable scale">Reset view</button>
146
+ </div>
147
+ </section>
148
+ <aside class="sidebar" aria-label="Column history and source inspector">
149
+ <div class="sidebar-heading"><p class="eyebrow">Column explorer</p>
150
+ <h2>{% if history_mode %}Usages and destinations{% else %}Inspect dependencies{% endif %}</h2>
151
+ <p>{% if history_mode %}Direct column actions, derived columns, and the DataFrames that carry them.
152
+ {% else %}Select a graph block to see its definition and evidence.{% endif %}</p>
153
+ </div>
154
+ <div class="sidebar-tabs" role="tablist" aria-label="Inspector panels">
155
+ <button id="tab-history" type="button" role="tab" aria-selected="true" aria-controls="panel-history"
156
+ tabindex="0">Journey <span class="tab-count">{{ stages|length }}</span></button>
157
+ <button id="tab-outcomes" type="button" role="tab" aria-selected="false" aria-controls="panel-outcomes"
158
+ tabindex="-1">Outcomes</button>
159
+ <button id="tab-details" type="button" role="tab" aria-selected="false" aria-controls="panel-details"
160
+ tabindex="-1">Details</button>
161
+ <button id="tab-diagnostics" type="button" role="tab" aria-selected="false" aria-controls="panel-diagnostics"
162
+ tabindex="-1">Diagnostics <span class="tab-count">{{ diagnostics|length }}</span></button>
163
+ </div>
164
+ <section id="panel-history" class="sidebar-panel history-scroll" role="tabpanel" aria-labelledby="tab-history">
165
+ {% if column_notice %}<p class="notice">{{ column_notice|e }}</p>{% endif %}
166
+ {% if history_notice %}<p class="notice">{{ history_notice|e }}</p>{% endif %}
167
+ {% if history_mode %}
168
+ <section aria-label="Ordered column history">
169
+ {% if journey.fate %}
170
+ <div class="fate-card {{ journey.fate.status|e }}"><p class="eyebrow">Observed destination</p>
171
+ <h3>{{ journey.fate.label|e }}</h3>
172
+ {% if journey.fate.location %}<p>{{ journey.fate.location.path|e }}:{{ journey.fate.location.line }}</p>{% endif %}
173
+ </div>
174
+ {% endif %}
175
+ <div class="history-summary"><span>{{ stages|length }} column journey stages</span><span id="active-step">Select a stage</span></div>
176
+ <ol class="history-list">
177
+ {% for stage in stages %}
178
+ <li class="stage {{ stage.category|e }}{% if stage.continuity == 'unresolved' %} unverified{% endif %}" id="stage-{{ loop.index }}">
179
+ <div class="stage-header"><span class="stage-number">STEP {{ '%02d'|format(loop.index) }}</span>
180
+ <span class="category-chip">{% if stage.continuity == 'unresolved' %}unverified export{% elif stage.relationship %}{{ stage.relationship|replace('_', ' ')|e }}{% elif stage.category == 'boundary' %}unresolved{% else %}{{ stage.category|e }}{% endif %}</span></div>
181
+ <h3><button type="button" class="focus-stage" data-node-id="{{ stage.id|e }}"
182
+ aria-label="Show step {{ loop.index }} in graph: {{ stage.label|e }}">{{ stage.label|e }}</button></h3>
183
+ <p class="stage-location"><strong>{{ stage.location.path|e }}:{{ stage.location.line }}</strong><br>
184
+ {{ stage.module|e }}::{{ stage.scope|e }}</p>
185
+ {% if stage.expression %}<pre class="stage-code"><code>{{ stage.expression|e }}</code></pre>{% endif %}
186
+ {% if stage.column_flow %}<p class="column-flow">{{ stage.column_flow|e }}</p>{% endif %}
187
+ {% if stage.explanation %}<p class="stage-explanation">{{ stage.explanation|e }}</p>{% endif %}
188
+ {% if stage.evidence != 'confirmed' or stage.completeness != 'complete' %}
189
+ <p class="evidence"><span>Evidence: {{ stage.evidence|e }}</span><span>Completeness: {{ stage.completeness|e }}</span></p>
190
+ {% endif %}
191
+ </li>
192
+ {% endfor %}
193
+ </ol>
194
+ {% if not stages %}<p class="empty-state">No recorded stages within the selected filters.</p>{% endif %}
195
+ <p class="history-footnote">Arrows express recorded causal links, not an execution log.
196
+ Selecting a block highlights its Upstream stages with blue outlines and Downstream stages with green outlines.
197
+ Dashed arrows indicate inferred or unresolved relations.
198
+ Row filters change row membership while preserving values of retained columns.
199
+ Supersedes links describe overwrite history, not value dependency.</p>
200
+ </section>
201
+ {% if creation_stages %}
202
+ <details class="creation-details" id="creation-context">
203
+ <summary>Creation context and full recorded history · {{ creation_stages|length }} stages</summary>
204
+ <p class="panel-description">This auxiliary history includes ancestors, callback internals, control, and configuration.
205
+ These are not all direct uses of the selected column.</p>
206
+ <ol class="history-list" aria-label="Auxiliary creation history">
207
+ {% for stage in creation_stages %}
208
+ <li class="stage {{ stage.category|e }}{% if stage.continuity == 'unresolved' %} unverified{% endif %}">
209
+ <div class="stage-header"><span class="stage-number">CONTEXT {{ '%02d'|format(loop.index) }}</span>
210
+ <span class="category-chip">{{ stage.category|e }}</span></div>
211
+ <h3>{{ stage.label|e }}</h3>
212
+ <p class="stage-location"><strong>{{ stage.location.path|e }}:{{ stage.location.line }}</strong><br>
213
+ {{ stage.module|e }}::{{ stage.scope|e }}</p>
214
+ {% if stage.expression %}<pre class="stage-code"><code>{{ stage.expression|e }}</code></pre>{% endif %}
215
+ </li>
216
+ {% endfor %}
217
+ </ol>
218
+ </details>
219
+ {% endif %}
220
+ {% else %}<span id="active-step" hidden></span>
221
+ <p class="empty-state">This view displays column dependencies.
222
+ Export a selected column with recorded history to follow its source, transformations, and uses.</p>
223
+ {% endif %}
224
+ </section>
225
+ <section id="panel-outcomes" class="sidebar-panel" role="tabpanel" aria-labelledby="tab-outcomes" hidden>
226
+ <h3 class="panel-title">What happens to this column?</h3>
227
+ <p class="panel-description">Destinations established by static analysis. A DataFrame use does not prove that an opaque function reads every column.</p>
228
+ {% if journey.fate %}<div class="fate-card {{ journey.fate.status|e }}"><h3>{{ journey.fate.label|e }}</h3>
229
+ {% if journey.fate.location %}<p>{{ journey.fate.location.path|e }}:{{ journey.fate.location.line }}</p>{% endif %}
230
+ {% if journey.fate.expression %}<pre class="stage-code"><code>{{ journey.fate.expression|e }}</code></pre>{% endif %}</div>{% endif %}
231
+ <section class="outcome-section" aria-label="Derived column destinations"><h3>Derived columns · {{ journey.derived_columns|length }}</h3>
232
+ {% for column in journey.derived_columns %}
233
+ <article class="outcome-card"><div class="outcome-heading"><h4>{{ column.name|e }}</h4>
234
+ <span class="outcome-meta">{{ column.relationship|e }}{% if column.distance > 1 %} · {{ column.distance }} hops{% endif %}</span></div>
235
+ {% if column.definition %}<p>{{ column.definition.location.path|e }}:{{ column.definition.location.line }} · {{ column.definition.scope|e }}</p>
236
+ <pre class="stage-code"><code>{{ column.definition.expression|e }}</code></pre>{% endif %}
237
+ {% if column.upstream %}<p>Value inputs: {% for input in column.upstream %}{{ input.name|e }}@{{ input.version }}{% if not loop.last %}, {% endif %}{% endfor %}</p>{% endif %}
238
+ <p class="outcome-fate">{{ column.fate.label|e }}{% if column.fate.location %}<br>
239
+ {{ column.fate.location.path|e }}:{{ column.fate.location.line }}{% endif %}</p>
240
+ {% for frame in column.dataframes %}<p>DataFrame: {% if frame.bindings %}{{ frame.bindings|map(attribute='name')|unique|join(', ')|e }}{% else %}{{ frame.origin|e }}{% endif %}<br>
241
+ <code>{{ frame.id|e }}</code></p>{% endfor %}
242
+ {% if column.steps %}<details><summary>Recorded uses · {{ column.steps|length }}</summary>
243
+ <ul class="binding-list">{% for step in column.steps %}<li>{{ step.relationship|replace('_', ' ')|e }} · {{ step.location.path|e }}:{{ step.location.line }} · {{ step.scope|e }}<br>{{ step.label|e }}</li>{% endfor %}</ul>
244
+ </details>{% endif %}
245
+ </article>
246
+ {% else %}<p class="empty-state">No derived columns were established within these query filters.</p>{% endfor %}
247
+ </section>
248
+ <section class="outcome-section" aria-label="Containing DataFrames"><h3>Containing DataFrames · {{ journey.dataframes|length }}</h3>
249
+ {% for frame in journey.dataframes %}<article class="frame-card"><h4>{% if frame.bindings %}{{ frame.bindings|map(attribute='name')|unique|join(', ')|e }}{% else %}DataFrame{% endif %}</h4>
250
+ <p>{{ frame.origin|e }} · {{ frame.module|e }}::{{ frame.scope|e }}<br>
251
+ {{ frame.location.path|e }}:{{ frame.location.line }}</p>
252
+ <ul class="binding-list">{% for binding in frame.bindings %}<li>{{ binding.name|e }} · {{ binding.scope|e }} · {{ binding.location.path|e }}:{{ binding.location.line }}</li>{% endfor %}</ul>
253
+ {% if frame.uses %}<details><summary>Observed column/frame uses · {{ frame.uses|length }}</summary>
254
+ <ul class="binding-list">{% for use in frame.uses %}<li>{{ use.kind|e }} · {{ use.scope|e }} · {{ use.location.path|e }}:{{ use.location.line }}</li>{% endfor %}</ul>
255
+ </details>{% endif %}
256
+ <code>{{ frame.id|e }}</code></article>
257
+ {% else %}<p class="empty-state">No containing DataFrame was established in this view.</p>{% endfor %}
258
+ </section>
259
+ <p class="history-footnote">“Last recorded use” describes the limit of the analyzed evidence. It does not establish that a column is unused elsewhere.</p>
260
+ </section>
261
+ <section id="panel-details" class="sidebar-panel" role="tabpanel" aria-labelledby="tab-details" hidden>
262
+ <h3 class="panel-title">Source details</h3><p class="panel-description">Full source expression, location, column flow, and analysis evidence for the selected block.</p>
263
+ <pre id="details">Select a node.</pre>
264
+ </section>
265
+ <section id="panel-diagnostics" class="sidebar-panel" role="tabpanel" aria-labelledby="tab-diagnostics" hidden>
266
+ <h3 class="panel-title">Diagnostics: {{ diagnostics|length }}{% if filtered %} in selected context{% endif %}</h3>
267
+ <p class="panel-description">These messages describe the limits of the identified lineage.</p>
268
+ {% if diagnostics %}<ul class="diagnostics-list">{% for diagnostic in diagnostics %}
269
+ <li class="diagnostic"><header><strong>{{ diagnostic.code|e }}</strong><small>{{ diagnostic.severity|e }}</small></header>
270
+ <small>{{ diagnostic.location.path|e }}:{{ diagnostic.location.line }} · {{ diagnostic.scope|e }}</small>
271
+ <p>{{ diagnostic.message|e }}</p>{% if diagnostic.hint %}<p class="hint">{{ diagnostic.hint|e }}</p>{% endif %}</li>
272
+ {% endfor %}</ul>{% else %}<p class="empty-state">No diagnostics in this view.</p>{% endif %}
273
+ </section>
274
+ </aside>
275
+ </main>
276
+ </div>
277
+ <script>
278
+ const nodes = new vis.DataSet({{nodes|tojson}});
279
+ const edges = new vis.DataSet({{edges|tojson}});
280
+ const baseNodes = nodes.get();
281
+ const stageIds = {{ stages|map(attribute='id')|list|tojson }};
282
+ const network = new vis.Network(document.getElementById('mynetwork'),
283
+ {nodes:nodes,edges:edges}, {{options|safe}});
284
+ const tabNames = ['history', 'outcomes', 'details', 'diagnostics'];
285
+ function showTab(name) {
286
+ tabNames.forEach(item => {
287
+ const tab = document.getElementById('tab-' + item);
288
+ tab.setAttribute('aria-selected', String(item === name));
289
+ tab.tabIndex = item === name ? 0 : -1;
290
+ document.getElementById('panel-' + item).hidden = item !== name;
291
+ });
292
+ }
293
+ tabNames.forEach((name, index) => {
294
+ const tab = document.getElementById('tab-' + name);
295
+ tab.addEventListener('click', () => showTab(name));
296
+ tab.addEventListener('keydown', event => {
297
+ let target = null;
298
+ if(event.key === 'ArrowRight') target = (index + 1) % tabNames.length;
299
+ if(event.key === 'ArrowLeft') target = (index + tabNames.length - 1) % tabNames.length;
300
+ if(event.key === 'Home') target = 0;
301
+ if(event.key === 'End') target = tabNames.length - 1;
302
+ if(target !== null) {
303
+ event.preventDefault();showTab(tabNames[target]);
304
+ document.getElementById('tab-' + tabNames[target]).focus();
305
+ }
306
+ });
307
+ });
308
+ showTab({{ ('history' if history_mode else 'details')|tojson }});
309
+ function connected(start, reverse) {
310
+ const found = new Set(), pending = [start];
311
+ while(pending.length) {
312
+ const current = pending.pop();
313
+ for(const edge of edges.get()) {
314
+ const source = reverse ? edge.to : edge.from;
315
+ const target = reverse ? edge.from : edge.to;
316
+ if(source === current && target !== start && !found.has(target)) {
317
+ found.add(target);pending.push(target);
318
+ }
319
+ }
320
+ }
321
+ return found;
322
+ }
323
+ function activateStage(id, scroll) {
324
+ let selectedIndex = -1;
325
+ stageIds.forEach((stageId, index) => {
326
+ const card = document.getElementById('stage-' + (index + 1));
327
+ const active = stageId === id;
328
+ card.classList.toggle('is-active', active);
329
+ if(active) {
330
+ selectedIndex = index;
331
+ const panel = document.getElementById('panel-history');
332
+ if(scroll && !panel.hidden) {
333
+ const top = card.offsetTop - 12;
334
+ const bottom = card.offsetTop + card.offsetHeight + 12;
335
+ if(top < panel.scrollTop || card.offsetHeight > panel.clientHeight - 24) {
336
+ panel.scrollTo({top:Math.max(0,top),behavior:'smooth'});
337
+ } else if(bottom > panel.scrollTop + panel.clientHeight) {
338
+ panel.scrollTo({top:bottom - panel.clientHeight,behavior:'smooth'});
339
+ }
340
+ }
341
+ }
342
+ });
343
+ document.getElementById('active-step').textContent = selectedIndex < 0 ? '' :
344
+ 'Step ' + String(selectedIndex + 1).padStart(2, '0') + ' of ' + stageIds.length;
345
+ }
346
+ function showDetails(id, scroll) {
347
+ document.getElementById('details').textContent = nodes.get(id).details;
348
+ activateStage(id, scroll);
349
+ }
350
+ function highlight(id, scroll=true) {
351
+ const up = connected(id,true), down = connected(id,false);
352
+ nodes.update(baseNodes.map(node => {
353
+ const related = node.id === id || up.has(node.id) || down.has(node.id);
354
+ const original = typeof node.color === 'string' ? node.color :
355
+ (node.color && node.color.background) || '#e2e8f0';
356
+ return {id:node.id,borderWidth:node.id === id ? 3 : 1,
357
+ color:{background:related ? original : '#edf1f3',
358
+ border:node.id === id ? '#087f8c' : up.has(node.id) ? '#2563eb' :
359
+ down.has(node.id) ? '#168366' : '#dae2e6',
360
+ highlight:{background:original,border:'#087f8c'},
361
+ hover:{background:original,border:'#087f8c'}},
362
+ font:{...(typeof node.font === 'object' ? node.font : {}),
363
+ color:related ? '#19343f' : '#8799a2'}};
364
+ }));
365
+ showDetails(id, scroll);
366
+ }
367
+ function updateZoom() {
368
+ document.getElementById('zoom-level').textContent = Math.round(network.getScale() * 100) + '%';
369
+ }
370
+ function focusNode(id) {
371
+ network.selectNodes([id]);highlight(id);
372
+ network.focus(id,{scale:.85,animation:{duration:240,easingFunction:'easeInOutQuad'}});
373
+ updateZoom();
374
+ }
375
+ network.on('selectNode', event => {if(event.nodes.length) highlight(event.nodes[0]);});
376
+ network.on('zoom', updateZoom);
377
+ document.querySelectorAll('.focus-stage').forEach(button => {
378
+ button.addEventListener('click', () => {
379
+ const id = button.dataset.nodeId;
380
+ if(nodes.get(id)) focusNode(id);
381
+ });
382
+ });
383
+ function changeZoom(factor) {
384
+ const scale = Math.max(.08, Math.min(2.5, network.getScale() * factor));
385
+ network.moveTo({scale:scale,animation:{duration:180,easingFunction:'easeInOutQuad'}});
386
+ updateZoom();
387
+ }
388
+ document.getElementById('zoom-in').addEventListener('click', () => changeZoom(1.2));
389
+ document.getElementById('zoom-out').addEventListener('click', () => changeZoom(1 / 1.2));
390
+ document.getElementById('fit').addEventListener('click', () => {
391
+ network.fit({animation:{duration:260,easingFunction:'easeInOutQuad'}});updateZoom();
392
+ });
393
+ const selected = baseNodes.find(node => node.selected) || baseNodes[0];
394
+ function resetView() {
395
+ nodes.update(baseNodes.map(node => ({...node,borderWidth:node.borderWidth || 1,
396
+ font:{...(typeof node.font === 'object' ? node.font : {}),color:'#19343f'}})));
397
+ network.unselectAll();
398
+ if(selected) {
399
+ network.selectNodes([selected.id]);showDetails(selected.id, false);
400
+ network.focus(selected.id,{scale:.85,animation:false});
401
+ } else {
402
+ document.getElementById('details').textContent = 'Select a node.';
403
+ }
404
+ updateZoom();
405
+ }
406
+ document.getElementById('reset').addEventListener('click', resetView);
407
+ if(selected) {network.selectNodes([selected.id]);showDetails(selected.id, false);}
408
+ let initialViewApplied = false;
409
+ network.on('afterDrawing', () => {
410
+ if(!initialViewApplied) {initialViewApplied = true;resetView();}
411
+ updateZoom();
412
+ });
413
+ if(!baseNodes.length) ['zoom-in','zoom-out','fit','reset'].forEach(id => {
414
+ document.getElementById(id).disabled = true;
415
+ });
416
+ </script>
417
+ </body>
418
+ </html>
@@ -0,0 +1,159 @@
1
+ """Deterministic coordinates for readable, static history graphs.
2
+
3
+ Layout uses recorded edges only. Sharing a level or a horizontal lane does not
4
+ establish a data dependency, and disconnected components remain disconnected.
5
+ The renderer must disable physics and hierarchical layout to keep these positions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections import defaultdict
12
+ from typing import Any, TypedDict
13
+
14
+ import networkx as nx
15
+
16
+ BLOCK_WIDTH = 300
17
+ BRANCH_SPACING = 440
18
+ MIN_RANK_SPACING = 220
19
+ COMPONENT_GAP = 240
20
+
21
+
22
+ class NodePosition(TypedDict):
23
+ x: float
24
+ y: float
25
+ level: int
26
+
27
+
28
+ def _height(node: dict[str, Any]) -> float:
29
+ # A conservative allowance for font17, 24px margins and wrapped labels.
30
+ lines = sum(
31
+ max(1, math.ceil(len(line) / 27)) for line in str(node.get("label", "")).split("\n")
32
+ )
33
+ return max(110.0, lines * 24.0 + 48.0)
34
+
35
+
36
+ def _spaced(desired: list[float]) -> list[float]:
37
+ """Preserve layer order and put at least one branch spacing between cards."""
38
+ if not desired:
39
+ return []
40
+ placed = [desired[0]]
41
+ for value in desired[1:]:
42
+ placed.append(max(value, placed[-1] + BRANCH_SPACING))
43
+ offset = (sum(desired) - sum(placed)) / len(placed)
44
+ return [value + offset for value in placed]
45
+
46
+
47
+ def _component_layout(
48
+ graph: nx.DiGraph[str], records: dict[str, dict[str, Any]]
49
+ ) -> dict[str, NodePosition]:
50
+ # Condensation keeps malformed/cyclic input finite without pretending that
51
+ # an arbitrary edge removal made it acyclic. Members of a cycle share a rank.
52
+ components = sorted(tuple(sorted(group)) for group in nx.strongly_connected_components(graph))
53
+ membership = {node: index for index, group in enumerate(components) for node in group}
54
+ condensed: nx.DiGraph[int] = nx.DiGraph()
55
+ condensed.add_nodes_from(range(len(components)))
56
+ condensed.add_edges_from(
57
+ (membership[source], membership[target])
58
+ for source, target in graph.edges
59
+ if membership[source] != membership[target]
60
+ )
61
+ ranks: dict[int, int] = {}
62
+ for component in nx.lexicographical_topological_sort(condensed):
63
+ ranks[component] = max(
64
+ (ranks[parent] + 1 for parent in condensed.predecessors(component)), default=0
65
+ )
66
+ levels = {node: ranks[membership[node]] for node in graph}
67
+ layers: dict[int, list[str]] = defaultdict(list)
68
+ for node in sorted(graph):
69
+ layers[levels[node]].append(node)
70
+
71
+ ordered_levels = sorted(layers)
72
+ # Stable barycentric sweeps keep callback branches beside their own chains.
73
+ for reverse in (False, True, False, True):
74
+ positions = {
75
+ node: float(index) for layer in layers.values() for index, node in enumerate(layer)
76
+ }
77
+ for level in sorted(layers, reverse=reverse):
78
+ adjacent = graph.successors if reverse else graph.predecessors
79
+
80
+ centers: dict[str, tuple[float, str]] = {}
81
+ for node in layers[level]:
82
+ neighbors = [positions[item] for item in adjacent(node) if levels[item] != level]
83
+ centers[node] = (
84
+ sum(neighbors) / len(neighbors) if neighbors else positions[node],
85
+ node,
86
+ )
87
+
88
+ layers[level].sort(key=centers.__getitem__)
89
+ positions.update({node: float(index) for index, node in enumerate(layers[level])})
90
+
91
+ xs = {
92
+ node: (index - (len(layer) - 1) / 2) * BRANCH_SPACING
93
+ for layer in layers.values()
94
+ for index, node in enumerate(layer)
95
+ }
96
+ for reverse in (False, True, False, True):
97
+ for level in sorted(layers, reverse=reverse):
98
+ adjacent = graph.successors if reverse else graph.predecessors
99
+ desired: list[float] = []
100
+ for node in layers[level]:
101
+ neighbors = [xs[item] for item in adjacent(node) if levels[item] != level]
102
+ desired.append(sum(neighbors) / len(neighbors) if neighbors else xs[node])
103
+ xs.update(zip(layers[level], _spaced(desired), strict=True))
104
+
105
+ heights = {
106
+ level: max(_height(records[node]) for node in layer) for level, layer in layers.items()
107
+ }
108
+ ys: dict[int, float] = {}
109
+ for index, level in enumerate(ordered_levels):
110
+ if index == 0:
111
+ ys[level] = heights[level] / 2 + 40
112
+ else:
113
+ previous = ordered_levels[index - 1]
114
+ gap = max(MIN_RANK_SPACING, (heights[previous] + heights[level]) / 2 + 90)
115
+ ys[level] = ys[previous] + gap
116
+ return {node: {"x": xs[node], "y": ys[levels[node]], "level": levels[node]} for node in graph}
117
+
118
+
119
+ def layout_history(
120
+ nodes: list[dict[str, Any]], edges: list[dict[str, Any]]
121
+ ) -> dict[str, NodePosition]:
122
+ """Position every node with wide branches and ample top-to-bottom spacing.
123
+
124
+ Nodes require unique nonempty string ``id`` values. Edges may use either
125
+ ``source``/``target`` or vis-network's ``from``/``to`` names. Dangling edges
126
+ are ignored; they cannot introduce invisible nodes into the layout. Input
127
+ ordering, Python hash order, and previous renderer runs do not affect output.
128
+ """
129
+ records: dict[str, dict[str, Any]] = {}
130
+ for node in nodes:
131
+ identity = node.get("id")
132
+ if not isinstance(identity, str) or not identity or identity in records:
133
+ raise ValueError("History layout requires unique, nonempty string node IDs")
134
+ records[identity] = node
135
+ graph: nx.DiGraph[str] = nx.DiGraph()
136
+ graph.add_nodes_from(sorted(records))
137
+ connections: set[tuple[str, str]] = set()
138
+ for edge in edges:
139
+ source, target = edge.get("source", edge.get("from")), edge.get("target", edge.get("to"))
140
+ if isinstance(source, str) and isinstance(target, str):
141
+ if source in records and target in records:
142
+ connections.add((source, target))
143
+ graph.add_edges_from(sorted(connections))
144
+
145
+ groups = sorted(tuple(sorted(group)) for group in nx.weakly_connected_components(graph))
146
+ result: dict[str, NodePosition] = {}
147
+ offset = 0.0
148
+ for group in groups:
149
+ positions = _component_layout(graph.subgraph(group).copy(), records)
150
+ left = min(position["x"] for position in positions.values())
151
+ right = max(position["x"] for position in positions.values())
152
+ for identity, position in positions.items():
153
+ result[identity] = {**position, "x": position["x"] - left + offset + BLOCK_WIDTH / 2}
154
+ offset += right - left + BLOCK_WIDTH + COMPONENT_GAP
155
+ center = (offset - COMPONENT_GAP) / 2 if result else 0.0
156
+ return {
157
+ identity: {**result[identity], "x": result[identity]["x"] - center}
158
+ for identity in sorted(result)
159
+ }