clyxconsultingdev commited on
Commit
b2679e6
·
verified ·
1 Parent(s): 7b18c51

Deploy the site from pipeline commit 1f003bf5caec

Browse files
Files changed (3) hide show
  1. assets/amc.css +42 -18
  2. assets/nav.css +7 -0
  3. consulta/index.html +122 -40
assets/amc.css CHANGED
@@ -22,7 +22,10 @@
22
  --up: #006300; --down: #b4232f;
23
  --shadow: 0 1px 2px rgba(0,0,0,.04), 0 6px 24px rgba(0,0,0,.05);
24
  --shadow-lift: 0 2px 4px rgba(0,0,0,.05), 0 14px 40px rgba(0,0,0,.09);
25
- --radius: 14px;
 
 
 
26
  --sans: Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
27
  --display: "Inter Tight", Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
28
  --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
@@ -121,10 +124,10 @@ section.block { padding: 56px 0 8px; }
121
  .sec-head p { color: var(--ink-2); margin: 8px 0 0; max-width: 64ch; }
122
  .lede { color: var(--ink-2); margin: 0 0 20px; max-width: 70ch; }
123
  .lede b { color: var(--ink); }
124
- .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow); }
125
 
126
  /* ---------------------------------------------------------------- story cards */
127
- .stories { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
128
  .stories.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
129
  @media (max-width: 960px) { .stories.three { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
130
  @media (max-width: 680px) { .stories, .stories.three { grid-template-columns: minmax(0, 1fr); } }
@@ -133,7 +136,7 @@ section.block { padding: 56px 0 8px; }
133
  .story-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-lift); border-color: var(--border-strong); }
134
  .story-card .thumb { aspect-ratio: 16 / 7.5; background: var(--surface-2); position: relative; border-bottom: 1px solid var(--border); }
135
  .story-card .thumb svg, .story-card .thumb canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
136
- .story-card .body { padding: 18px 20px 20px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
137
  .story-card h3 { font-size: 21px; line-height: 1.2; margin: 0; }
138
  .story-card p { margin: 0; color: var(--ink-2); font-size: 14.5px; }
139
  .story-card .more { margin-top: auto; padding-top: 6px; font-weight: 600; font-size: 14px; color: var(--ink); }
@@ -147,10 +150,10 @@ section.block { padding: 56px 0 8px; }
147
  }
148
 
149
  /* ---------------------------------------------------------------- facts */
150
- .facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
151
  @media (max-width: 860px) { .facts { grid-template-columns: minmax(0, 1fr); } }
152
- .fact { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 22px; box-shadow: var(--shadow);
153
- display: flex; flex-direction: column; gap: 10px; }
154
  .fact .big { font: 800 clamp(34px, 4.6vw, 46px)/1 var(--display); letter-spacing: -.035em; margin: 0; }
155
  .fact p { margin: 0; color: var(--ink-2); font-size: 15px; }
156
  .fact p.big, .finding p.n { color: var(--ink); }
@@ -159,9 +162,9 @@ section.block { padding: 56px 0 8px; }
159
  .fact a:hover { text-decoration: underline; }
160
 
161
  /* ---------------------------------------------------------------- stat tiles */
162
- .tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 0 0 8px; }
163
  @media (max-width: 900px) { .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
164
- .tile { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px; box-shadow: var(--shadow); }
165
  .tile .label { font-size: 14px; color: var(--ink-2); margin: 0; }
166
  .tile .value { font: 700 30px/1.15 var(--display); margin: 6px 0 4px; letter-spacing: -.02em; }
167
  .tile .sub { font-size: 13px; color: var(--muted); margin: 0; }
@@ -177,10 +180,10 @@ section.block { padding: 56px 0 8px; }
177
  .share:hover { color: var(--ink); border-color: var(--border-strong); }
178
  .share svg { width: 13px; height: 13px; }
179
 
180
- .findings { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 36px 0 12px; counter-reset: f; }
181
  @media (max-width: 820px) { .findings { grid-template-columns: minmax(0, 1fr); } }
182
- .finding { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow); }
183
- .finding .n { font: 800 clamp(30px, 3.8vw, 40px)/1 var(--display); letter-spacing: -.03em; margin: 0 0 10px; }
184
  .finding p { margin: 0; color: var(--ink-2); font-size: 15px; line-height: 1.5; }
185
  .finding p b { color: var(--ink); }
186
 
@@ -207,7 +210,7 @@ figure.fig.read { max-width: var(--read); }
207
 
208
  /* "Cómo leerlo" — the nuance, next to the figure it qualifies */
209
  aside.read-this { max-width: var(--read); margin: 28px auto; border-left: 3px solid var(--accent); background: var(--surface);
210
- border-radius: 0 12px 12px 0; padding: 16px 20px; box-shadow: var(--shadow); font-size: 15.5px; line-height: 1.6; }
211
  aside.read-this .t { font: 700 13px/1 var(--sans); letter-spacing: .06em; text-transform: uppercase; color: var(--accent); margin: 0 0 8px; }
212
  aside.read-this p { margin: 0 0 .6em; color: var(--ink-2); }
213
  aside.read-this p:last-child { margin-bottom: 0; }
@@ -296,16 +299,16 @@ details.tv th { background: var(--surface-2); color: var(--ink-2); font-weight:
296
  details.tv td.n, details.tv th.n { text-align: right; font-variant-numeric: tabular-nums; }
297
 
298
  /* ---------------------------------------------------------------- what changed, cta, trust */
299
- .changes { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
300
  @media (max-width: 760px) { .changes { grid-template-columns: minmax(0, 1fr); } }
301
- .change { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px; box-shadow: var(--shadow); }
302
  .change .value { font: 700 26px/1.2 var(--display); margin: 0; letter-spacing: -.02em; }
303
  .change .label { margin: 2px 0 0; font-weight: 600; }
304
  .change .sub { margin: 4px 0 0; color: var(--ink-2); font-size: 14px; }
305
- .cta { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
306
  @media (max-width: 860px) { .cta { grid-template-columns: minmax(0, 1fr); } }
307
  .cta a { display: block; text-decoration: none; background: var(--surface); border: 1px solid var(--border);
308
- border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow); transition: transform .2s, box-shadow .2s, border-color .2s; }
309
  .cta a:hover { transform: translateY(-2px); box-shadow: var(--shadow-lift); border-color: var(--border-strong); }
310
  .cta a.primary { background: var(--ink); border-color: var(--ink); color: var(--page); }
311
  .cta .t { font: 700 18px var(--display); letter-spacing: -.01em; }
@@ -357,7 +360,7 @@ nav.pager a { display: inline-block; padding: 10px 0; min-height: 24px; }
357
  .scrolly .graphic { grid-row: 1; grid-column: 1; position: sticky; top: 76px; height: calc(100vh - 96px); display: flex; flex-direction: column; justify-content: center; }
358
  .scrolly .graphic .chart.map svg { max-height: calc(100vh - 210px); }
359
  .scrolly .steps { grid-row: 1; grid-column: 1; z-index: 1; padding: 62vh 0 30vh; pointer-events: none; }
360
- .scrolly .step { pointer-events: auto; max-width: 440px; margin: 0 auto 80vh; padding: 16px 18px; background: var(--surface); border: 1px solid var(--border);
361
  border-radius: var(--radius); box-shadow: var(--shadow-lift); font-size: 16.5px; line-height: 1.55; opacity: .78; transition: opacity .3s; }
362
  .scrolly .step.on { opacity: 1; }
363
  .scrolly .step p { margin: 0; }
@@ -377,6 +380,27 @@ nav.pager a { display: inline-block; padding: 10px 0; min-height: 24px; }
377
  .reveal.in { opacity: 1; transform: none; }
378
  @media (prefers-reduced-motion: reduce) { .reveal { opacity: 1; transform: none; transition: none; } }
379
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  @media print {
381
  nav.top, .share, .fig-foot .acts, footer.site { display: none; }
382
  body { background: #fff; }
 
22
  --up: #006300; --down: #b4232f;
23
  --shadow: 0 1px 2px rgba(0,0,0,.04), 0 6px 24px rgba(0,0,0,.05);
24
  --shadow-lift: 0 2px 4px rgba(0,0,0,.05), 0 14px 40px rgba(0,0,0,.09);
25
+ --radius: 14px; --radius-sm: 12px;
26
+ /* One spacing system for every box: its padding, the gap between boxes in a
27
+ row, and the gap between a box's number, title and text. */
28
+ --pad: 20px; --pad-tight: 16px; --gap: 16px; --gap-tight: 12px; --stack: 10px;
29
  --sans: Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
30
  --display: "Inter Tight", Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
31
  --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
 
124
  .sec-head p { color: var(--ink-2); margin: 8px 0 0; max-width: 64ch; }
125
  .lede { color: var(--ink-2); margin: 0 0 20px; max-width: 70ch; }
126
  .lede b { color: var(--ink); }
127
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--pad); box-shadow: var(--shadow); }
128
 
129
  /* ---------------------------------------------------------------- story cards */
130
+ .stories { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--gap); }
131
  .stories.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
132
  @media (max-width: 960px) { .stories.three { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
133
  @media (max-width: 680px) { .stories, .stories.three { grid-template-columns: minmax(0, 1fr); } }
 
136
  .story-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-lift); border-color: var(--border-strong); }
137
  .story-card .thumb { aspect-ratio: 16 / 7.5; background: var(--surface-2); position: relative; border-bottom: 1px solid var(--border); }
138
  .story-card .thumb svg, .story-card .thumb canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
139
+ .story-card .body { padding: var(--pad); display: flex; flex-direction: column; gap: var(--stack); flex: 1; }
140
  .story-card h3 { font-size: 21px; line-height: 1.2; margin: 0; }
141
  .story-card p { margin: 0; color: var(--ink-2); font-size: 14.5px; }
142
  .story-card .more { margin-top: auto; padding-top: 6px; font-weight: 600; font-size: 14px; color: var(--ink); }
 
150
  }
151
 
152
  /* ---------------------------------------------------------------- facts */
153
+ .facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--gap); }
154
  @media (max-width: 860px) { .facts { grid-template-columns: minmax(0, 1fr); } }
155
+ .fact { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--pad); box-shadow: var(--shadow);
156
+ display: flex; flex-direction: column; gap: var(--stack); }
157
  .fact .big { font: 800 clamp(34px, 4.6vw, 46px)/1 var(--display); letter-spacing: -.035em; margin: 0; }
158
  .fact p { margin: 0; color: var(--ink-2); font-size: 15px; }
159
  .fact p.big, .finding p.n { color: var(--ink); }
 
162
  .fact a:hover { text-decoration: underline; }
163
 
164
  /* ---------------------------------------------------------------- stat tiles */
165
+ .tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--gap-tight); margin: 0 0 8px; }
166
  @media (max-width: 900px) { .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
167
+ .tile { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--pad-tight); box-shadow: var(--shadow); }
168
  .tile .label { font-size: 14px; color: var(--ink-2); margin: 0; }
169
  .tile .value { font: 700 30px/1.15 var(--display); margin: 6px 0 4px; letter-spacing: -.02em; }
170
  .tile .sub { font-size: 13px; color: var(--muted); margin: 0; }
 
180
  .share:hover { color: var(--ink); border-color: var(--border-strong); }
181
  .share svg { width: 13px; height: 13px; }
182
 
183
+ .findings { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--gap); margin: 36px 0 12px; counter-reset: f; }
184
  @media (max-width: 820px) { .findings { grid-template-columns: minmax(0, 1fr); } }
185
+ .finding { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--pad); box-shadow: var(--shadow); }
186
+ .finding .n { font: 800 clamp(30px, 3.8vw, 40px)/1 var(--display); letter-spacing: -.03em; margin: 0 0 var(--stack); }
187
  .finding p { margin: 0; color: var(--ink-2); font-size: 15px; line-height: 1.5; }
188
  .finding p b { color: var(--ink); }
189
 
 
210
 
211
  /* "Cómo leerlo" — the nuance, next to the figure it qualifies */
212
  aside.read-this { max-width: var(--read); margin: 28px auto; border-left: 3px solid var(--accent); background: var(--surface);
213
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0; padding: var(--pad-tight) var(--pad); box-shadow: var(--shadow); font-size: 15.5px; line-height: 1.6; }
214
  aside.read-this .t { font: 700 13px/1 var(--sans); letter-spacing: .06em; text-transform: uppercase; color: var(--accent); margin: 0 0 8px; }
215
  aside.read-this p { margin: 0 0 .6em; color: var(--ink-2); }
216
  aside.read-this p:last-child { margin-bottom: 0; }
 
299
  details.tv td.n, details.tv th.n { text-align: right; font-variant-numeric: tabular-nums; }
300
 
301
  /* ---------------------------------------------------------------- what changed, cta, trust */
302
+ .changes { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--gap-tight); }
303
  @media (max-width: 760px) { .changes { grid-template-columns: minmax(0, 1fr); } }
304
+ .change { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--pad-tight); box-shadow: var(--shadow); }
305
  .change .value { font: 700 26px/1.2 var(--display); margin: 0; letter-spacing: -.02em; }
306
  .change .label { margin: 2px 0 0; font-weight: 600; }
307
  .change .sub { margin: 4px 0 0; color: var(--ink-2); font-size: 14px; }
308
+ .cta { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--gap-tight); }
309
  @media (max-width: 860px) { .cta { grid-template-columns: minmax(0, 1fr); } }
310
  .cta a { display: block; text-decoration: none; background: var(--surface); border: 1px solid var(--border);
311
+ border-radius: var(--radius); padding: var(--pad); box-shadow: var(--shadow); transition: transform .2s, box-shadow .2s, border-color .2s; }
312
  .cta a:hover { transform: translateY(-2px); box-shadow: var(--shadow-lift); border-color: var(--border-strong); }
313
  .cta a.primary { background: var(--ink); border-color: var(--ink); color: var(--page); }
314
  .cta .t { font: 700 18px var(--display); letter-spacing: -.01em; }
 
360
  .scrolly .graphic { grid-row: 1; grid-column: 1; position: sticky; top: 76px; height: calc(100vh - 96px); display: flex; flex-direction: column; justify-content: center; }
361
  .scrolly .graphic .chart.map svg { max-height: calc(100vh - 210px); }
362
  .scrolly .steps { grid-row: 1; grid-column: 1; z-index: 1; padding: 62vh 0 30vh; pointer-events: none; }
363
+ .scrolly .step { pointer-events: auto; max-width: 440px; margin: 0 auto 80vh; padding: var(--pad-tight) var(--pad); background: var(--surface); border: 1px solid var(--border);
364
  border-radius: var(--radius); box-shadow: var(--shadow-lift); font-size: 16.5px; line-height: 1.55; opacity: .78; transition: opacity .3s; }
365
  .scrolly .step.on { opacity: 1; }
366
  .scrolly .step p { margin: 0; }
 
380
  .reveal.in { opacity: 1; transform: none; }
381
  @media (prefers-reduced-motion: reduce) { .reveal { opacity: 1; transform: none; transition: none; } }
382
 
383
+ /* ---------------------------------------------------------------- typography
384
+ Text in boxes reads as one family on every screen. Headings and the short
385
+ lines of a card balance their lines; paragraphs avoid a lone last word.
386
+ Every page is lang="es", so the browser hyphenates by Spanish rules: in the
387
+ boxes always, in the prose only on a phone, where a long word otherwise
388
+ leaves a ragged gap. Figures that sit side by side use tabular digits.
389
+ Long prose is justified only where its column is wide enough to take it,
390
+ and always hyphenated; a card, a caption or a figure is never justified. */
391
+ h1, h2, h3, .hero .line, .story-head .dek, .fig-head h3, .story-card h3, .cta .t, .tile .label, .change .label { text-wrap: balance; }
392
+ p, li, .fig-head p, .cta .d { text-wrap: pretty; }
393
+ .card, .fact, .finding, .tile, .change, .story-card .body, .cta a, .trust p, .fig-head p, aside.read-this, .scrolly .step, #tip {
394
+ -webkit-hyphens: auto; hyphens: auto; hyphenate-limit-chars: 7 3 3; overflow-wrap: break-word; }
395
+ .fact .big, .finding .n, .tile .value, .change .value, .story-card .stat { font-variant-numeric: tabular-nums; -webkit-hyphens: manual; hyphens: manual; }
396
+ @media (max-width: 540px) {
397
+ .prose { -webkit-hyphens: auto; hyphens: auto; hyphenate-limit-chars: 7 3 3; }
398
+ }
399
+ @media (min-width: 900px) {
400
+ .prose > p { text-align: justify; -webkit-hyphens: auto; hyphens: auto; hyphenate-limit-chars: 7 3 3; }
401
+ figure .prose > p { text-align: start; }
402
+ }
403
+
404
  @media print {
405
  nav.top, .share, .fig-foot .acts, footer.site { display: none; }
406
  body { background: #fff; }
assets/nav.css CHANGED
@@ -18,6 +18,13 @@ nav.top .wrap { max-width: 1080px; margin: 0 auto; padding: 0 16px; display: fle
18
  nav.top .brand { display: inline-flex; align-items: center; gap: 10px; text-decoration: none; white-space: nowrap; color: var(--n-ink); }
19
  nav.top .brand .mark { height: 30px; padding: 0 8px; border-radius: 8px; background: var(--n-mark); color: var(--n-mark-ink);
20
  display: grid; place-items: center; font: 800 12.5px/1 "Inter Tight", Inter, system-ui, sans-serif; letter-spacing: .04em; }
 
 
 
 
 
 
 
21
  nav.top .brand .name { font: 700 15px/1.1 "Inter Tight", Inter, system-ui, sans-serif; letter-spacing: -.01em; }
22
  nav.top .brand .name small { display: block; font: 500 12px/1.2 Inter, system-ui, sans-serif; color: var(--n-muted); letter-spacing: 0; }
23
  nav.top .links { display: flex; gap: 2px; margin-left: auto; align-items: center; }
 
18
  nav.top .brand { display: inline-flex; align-items: center; gap: 10px; text-decoration: none; white-space: nowrap; color: var(--n-ink); }
19
  nav.top .brand .mark { height: 30px; padding: 0 8px; border-radius: 8px; background: var(--n-mark); color: var(--n-mark-ink);
20
  display: grid; place-items: center; font: 800 12.5px/1 "Inter Tight", Inter, system-ui, sans-serif; letter-spacing: .04em; }
21
+ /* Colombia's colours, not its flag: a strip in the flag's bands (yellow half,
22
+ blue and red a quarter each) along the foot of the mark. The flag itself, or
23
+ its arms, would read as a government site, which AMC is careful not to be. */
24
+ nav.top .brand .mark { position: relative; overflow: hidden; box-sizing: border-box; padding-bottom: 4px; }
25
+ nav.top .brand .mark::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 4px;
26
+ background: linear-gradient(90deg, #fcd116 0 50%, #003893 50% 75%, #ce1126 75%); }
27
+ @media (forced-colors: active) { nav.top .brand .mark::after { display: none; } }
28
  nav.top .brand .name { font: 700 15px/1.1 "Inter Tight", Inter, system-ui, sans-serif; letter-spacing: -.01em; }
29
  nav.top .brand .name small { display: block; font: 500 12px/1.2 Inter, system-ui, sans-serif; color: var(--n-muted); letter-spacing: 0; }
30
  nav.top .links { display: flex; gap: 2px; margin-left: auto; align-items: center; }
consulta/index.html CHANGED
@@ -257,38 +257,78 @@ import * as duckdb from "https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.32.0
257
  const BASE = "https://huggingface.co/datasets/clyxconsultingdev/amc-rues/resolve/main";
258
  const DISPLAY_CAP = 1000;
259
 
260
- // Each one was run against the published files before it went in this list.
 
 
 
 
261
  const EXAMPLES = [
262
- ["Empresas vigentes por sector",
263
- "-- Personas jurídicas vigentes (la lista objetivo), por sección CIIU\nSELECT s.section, s.label, count(*) AS empresas\nFROM companies c JOIN sector_sections s USING (section)\nWHERE c.in_target_list\nGROUP BY ALL ORDER BY empresas DESC;"],
264
- ["Empresas creadas por día en cada gobierno",
265
- "-- Las mismas medidas para todos: el ritmo diario, porque los periodos no duran lo mismo\nSELECT p.periodo, p.presidente,\n count(*) AS creadas,\n round(count(*) / (coalesce(p.fin, current_date) - p.inicio), 0) AS por_dia\nFROM periods p\nJOIN companies c ON c.first_registered >= p.inicio\n AND c.first_registered < coalesce(p.fin, current_date)\nGROUP BY ALL ORDER BY p.inicio;"],
266
- ["Las actividades que más empresas crean",
267
- "SELECT a.clase, a.descripcion, count(*) AS creadas_2025\nFROM companies c JOIN activities a ON a.clase = c.sector_code\nWHERE c.formation_year = 2025\nGROUP BY ALL ORDER BY creadas_2025 DESC\nLIMIT 20;"],
268
- ["Cancelaciones de matrículas por año",
269
- "-- 2011 y 2015: las depuraciones del registro, no cierres económicos\nSELECT year(fecha) AS anio, sum(matriculas) AS canceladas,\n sum(matriculas) FILTER (WHERE estado ILIKE '%1429%') AS por_ley_1429\nFROM registry_events\nWHERE evento = 'cancelación' AND fecha >= DATE '2000-01-01'\nGROUP BY 1 ORDER BY 1;"],
270
- ["¿Dónde están? Por cámara de comercio",
 
 
 
 
 
 
 
271
  "SELECT chamber AS camara, count(*) AS empresas\nFROM companies\nWHERE in_target_list\nGROUP BY chamber ORDER BY empresas DESC\nLIMIT 15;"],
272
- ["Empresas creadas por año, y cuántas siguen",
273
- "SELECT formation_year AS anio,\n count(*) AS creadas,\n count(*) FILTER (WHERE is_live) AS vigentes_2026,\n round(100.0 * count(*) FILTER (WHERE is_live) / count(*), 1) AS pct_vigentes\nFROM companies\nWHERE formation_year BETWEEN 2000 AND 2026 AND org_type <> 'persona natural'\nGROUP BY formation_year ORDER BY formation_year;"],
274
- ["Buscar una empresa por nombre",
275
- "SELECT display_name AS razon_social, nit, chamber AS camara, legal_form, section, first_registered, is_live\nFROM companies\nWHERE display_name ILIKE '%alpina%'\nORDER BY is_live DESC, first_registered\nLIMIT 25;"],
276
- ["El estado no es lo mismo que la vigencia",
277
- "-- Millones de matrículas dicen ACTIVA sin haber renovado, y al revés\nSELECT status AS estado, is_live AS vigente, count(*) AS empresas\nFROM companies\nGROUP BY ALL ORDER BY empresas DESC\nLIMIT 12;"],
278
- ["De 8,3 millones a la lista objetivo",
279
- "SELECT step AS paso, label, remaining AS quedan, lost_here AS salen, predicate\nFROM funnel ORDER BY step;"],
280
- ["Lo que cada total deja por fuera",
281
- "SELECT population, companies AS empresas, note FROM coverage;"],
282
- ["Quién está en el registro, por tipo de organización",
283
- "SELECT org_type, count(*) AS empresas,\n count(*) FILTER (WHERE is_live) AS vigentes\nFROM companies GROUP BY org_type ORDER BY empresas DESC;"],
284
- ["Las sociedades más recientes",
285
- "SELECT display_name AS razon_social, legal_form, chamber AS camara, section, first_registered\nFROM companies\nWHERE org_type IN ('sociedad', 'sin ánimo de lucro', 'economía solidaria')\nORDER BY first_registered DESC\nLIMIT 25;"],
286
- ["Las empresas que más venden (estados financieros)",
287
- "-- Solo las empresas que reportan a la Superintendencia de Sociedades. Ingresos en miles de pesos\nSELECT c.display_name AS razon_social, c.chamber AS camara, f.anio_fiscal,\n round(f.ingresos / 1e6, 1) AS ingresos_miles_de_millones\nFROM financials f JOIN companies c USING (nit)\nWHERE f.anio_fiscal = (SELECT max(anio_fiscal) FROM financials)\nORDER BY f.ingresos DESC NULLS LAST\nLIMIT 25;"],
288
- ["¿Qué edad tienen las empresas que reportan?",
289
- "-- Sociedades vigentes, según si reportaron estados financieros el último año disponible\nSELECT CASE WHEN f.nit IS NULL THEN 'no reportan' ELSE 'reportan' END AS grupo,\n count(*) AS sociedades,\n median(year(current_date) - c.formation_year) AS edad_mediana\nFROM companies c\nLEFT JOIN (SELECT DISTINCT nit FROM financials\n WHERE anio_fiscal = (SELECT max(anio_fiscal) FROM financials)) f USING (nit)\nWHERE c.is_live AND c.org_type = 'sociedad'\nGROUP BY ALL;"],
290
- ["Cuántas renovaron alguna vez, por año de creación",
291
- "-- De las que renovaron alguna vez o nunca, qué parte renovó después de su año de creación\nSELECT formation_year AS anio,\n sum(cohort_size) AS cohorte,\n round(100.0 * sum(renewed) / nullif(sum(renewed) + sum(never_renewed), 0), 1) AS pct_renovo\nFROM survival\nWHERE formation_year BETWEEN 2000 AND 2025\nGROUP BY formation_year ORDER BY formation_year;"],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  ];
293
 
294
  const $ = (id) => document.getElementById(id);
@@ -364,17 +404,41 @@ async function startDuckDB() {
364
  // companies.parquet from Hugging Face before the first query (measured
365
  // 2026-10-14). With ranges it reads the footer and the columns it needs.
366
  await db.open({ query: { castDecimalToDouble: true }, filesystem: { forceFullHTTPReads: false } });
367
- return db.connect();
 
 
 
 
368
  }
369
 
370
  // One view per published file, named after it. The list comes from the
371
  // manifest, so a table added to the edition appears here without an edit.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  async function createViews(m) {
373
- const names = Object.keys(m.files).map((f) => f.replace(/\.parquet$/, ""));
374
- for (const n of names) {
375
- await conn.query(`CREATE OR REPLACE VIEW ${n} AS SELECT * FROM read_parquet('${BASE}/data/${n}.parquet')`);
 
376
  }
377
- return names;
 
378
  }
379
 
380
  async function renderSchema(m, names) {
@@ -443,16 +507,29 @@ function cell(v, type, name = "") {
443
  return { text: String(v), cls: "" };
444
  }
445
 
 
 
 
 
446
  async function run() {
447
  const sql = $("sql").value;
448
  if (!sql.trim() || !conn) return;
 
449
  $("run").disabled = true;
450
  const t0 = performance.now();
451
  const tick = setInterval(() => setStatus("busy", `Ejecutando… ${((performance.now() - t0) / 1000).toLocaleString("es-CO", { maximumFractionDigits: 1, minimumFractionDigits: 1 })} s`), 200);
452
  setStatus("busy", "Ejecutando…");
453
  try {
454
  const q = displayable(sql);
455
- const res = await conn.query(q.sql);
 
 
 
 
 
 
 
 
456
  const secs = ((performance.now() - t0) / 1000).toFixed(2);
457
  const fields = res.schema.fields;
458
  let rows = res.toArray().map((r) => r.toJSON());
@@ -464,13 +541,14 @@ async function run() {
464
  $("csv").disabled = rows.length === 0;
465
  history.replaceState(null, "", `#q=${encodeURIComponent(sql)}`);
466
  } catch (e) {
 
467
  lastRows = null;
468
  $("results").replaceChildren(el("div", { class: "msg err", text: String(e.message || e) }));
469
  setStatus("err", "Error en la consulta");
470
  $("csv").disabled = true;
471
  } finally {
472
  clearInterval(tick);
473
- $("run").disabled = false;
474
  }
475
  }
476
 
@@ -520,10 +598,14 @@ async function copyLink() {
520
  }
521
 
522
  // ---------------------------------------------------------------- wiring
523
- for (const [i, [title]] of EXAMPLES.entries()) $("examples").append(el("option", { value: String(i), text: title }));
 
 
 
 
524
  $("examples").addEventListener("change", (e) => {
525
  const ex = EXAMPLES[Number(e.target.value)];
526
- if (ex) { $("sql").value = ex[1]; run(); }
527
  e.target.value = "";
528
  });
529
  $("run").addEventListener("click", run);
@@ -535,7 +617,7 @@ $("sql").addEventListener("keydown", (e) => {
535
  });
536
 
537
  const fromLink = new URLSearchParams(location.hash.slice(1)).get("q");
538
- $("sql").value = fromLink || EXAMPLES[0][1];
539
 
540
  (async () => {
541
  try {
 
257
  const BASE = "https://huggingface.co/datasets/clyxconsultingdev/amc-rues/resolve/main";
258
  const DISPLAY_CAP = 1000;
259
 
260
+ // Thirty, from a first SELECT to window functions, grouped by what each one
261
+ // teaches. Each was run in this page, against the published files, before it
262
+ // went in the list (scratchpad check, 2026-10-16). The slow ones read the
263
+ // 8.3-million-row `companies`; where a summary table answers the same
264
+ // question, the example uses it and says so.
265
  const EXAMPLES = [
266
+ ["Primeros pasos", "Empresas vigentes por sector",
267
+ "-- SELECT, JOIN, WHERE, GROUP BY y ORDER BY en una sola consulta.\n-- Personas jurídicas vigentes (la lista objetivo), por sección CIIU\nSELECT s.section, s.label, count(*) AS empresas\nFROM companies c JOIN sector_sections s USING (section)\nWHERE c.in_target_list\nGROUP BY ALL ORDER BY empresas DESC;"],
268
+ ["Primeros pasos", "¿Cuántas empresas llevan una palabra en el nombre?",
269
+ "-- name_words guarda cada palabra de cada razón social, en orden alfabético:\n-- buscar una palabra exacta solo lee el trozo del archivo donde está, y es rápido.\n-- Escríbela en mayúsculas y sin tildes, y prueba otras: SOFTWARE, TRANSPORTES, ARTESANIAS\nSELECT palabra, count(*) AS empresas,\n count(*) FILTER (WHERE vivo) AS vigentes,\n count(*) FILTER (WHERE primera) AS como_primera_palabra\nFROM name_words\nWHERE palabra = 'CAFE'\nGROUP BY palabra;"],
270
+ ["Primeros pasos", "Buscar un fragmento de nombre con ILIKE",
271
+ "-- ILIKE busca un texto en cualquier parte, sin distinguir mayúsculas: '%' es cualquier cosa.\n-- Tiene que leer los 8,3 millones de nombres, así que tarda más que contar una palabra.\nSELECT display_name AS razon_social, nit, chamber AS camara, legal_form, first_registered, is_live AS vigente\nFROM companies\nWHERE display_name ILIKE '%alpina%'\nORDER BY is_live DESC, first_registered\nLIMIT 25;"],
272
+ ["Primeros pasos", "Las sociedades más recientes",
273
+ "-- ORDER BY ... DESC y LIMIT: las últimas inscritas en el registro\nSELECT display_name AS razon_social, legal_form, chamber AS camara, section, first_registered\nFROM companies\nWHERE org_type IN ('sociedad', 'sin ánimo de lucro', 'economía solidaria')\nORDER BY first_registered DESC\nLIMIT 25;"],
274
+ ["Primeros pasos", "Las leyes que cambiaron el registro",
275
+ "-- Una tabla pequeña para empezar: los hitos que explican saltos en las cifras\nSELECT fecha, titulo, detalle, fuente\nFROM milestones\nORDER BY fecha;"],
276
+ ["Primeros pasos", "¿Qué código CIIU tiene una actividad?",
277
+ "-- OR combina condiciones; cada clase CIIU es una actividad económica\nSELECT clase, descripcion\nFROM activities\nWHERE descripcion ILIKE '%software%' OR descripcion ILIKE '%informática%'\nORDER BY clase;"],
278
+
279
+ ["Contar y agrupar", "Quién está en el registro, por tipo de organización",
280
+ "-- count(*) FILTER (WHERE ...) cuenta solo una parte del grupo\nSELECT org_type, count(*) AS empresas,\n count(*) FILTER (WHERE is_live) AS vigentes\nFROM companies GROUP BY org_type ORDER BY empresas DESC;"],
281
+ ["Contar y agrupar", "¿Dónde están? Por cámara de comercio",
282
  "SELECT chamber AS camara, count(*) AS empresas\nFROM companies\nWHERE in_target_list\nGROUP BY chamber ORDER BY empresas DESC\nLIMIT 15;"],
283
+ ["Contar y agrupar", "El estado no es lo mismo que la vigencia",
284
+ "-- Agrupar por dos columnas cruza sus valores.\n-- Millones de matrículas dicen ACTIVA sin haber renovado, y al revés\nSELECT status AS estado, is_live AS vigente, count(*) AS empresas\nFROM companies\nGROUP BY ALL ORDER BY empresas DESC\nLIMIT 12;"],
285
+ ["Contar y agrupar", "Empresas creadas por año, y cuántas siguen",
286
+ "-- cohort es companies ya resumida por año de creación, cámara, sector y forma jurídica\n-- (las empresas del paso 3 de funnel): se suma con sum(), no se cuenta con count(),\n-- y responde en un instante\nSELECT formation_year AS anio,\n sum(companies) AS creadas,\n sum(live_companies) AS vigentes,\n round(100.0 * sum(live_companies) / sum(companies), 1) AS pct_vigentes\nFROM cohort\nWHERE formation_year BETWEEN 2000 AND 2026 AND is_juridical\nGROUP BY formation_year ORDER BY formation_year;"],
287
+ ["Contar y agrupar", "Las formas jurídicas de las empresas nuevas",
288
+ "-- WHERE filtra filas antes de agrupar; HAVING filtra grupos después\nSELECT legal_form AS forma_juridica, sum(companies) AS creadas_desde_2020\nFROM cohort\nWHERE formation_year >= 2020 AND is_juridical\nGROUP BY legal_form\nHAVING sum(companies) >= 100\nORDER BY creadas_desde_2020 DESC;"],
289
+ ["Contar y agrupar", "Cancelaciones de matrículas por año",
290
+ "-- year() saca el año de una fecha.\n-- 2011 y 2015: las depuraciones del registro, no cierres económicos\nSELECT year(fecha) AS anio, sum(matriculas) AS canceladas,\n sum(matriculas) FILTER (WHERE estado ILIKE '%1429%') AS por_ley_1429\nFROM registry_events\nWHERE evento = 'cancelación' AND fecha >= DATE '2000-01-01'\nGROUP BY 1 ORDER BY 1;"],
291
+
292
+ ["Contar y agrupar", "Cuántas renovaron alguna vez, por año de creación",
293
+ "-- nullif() evita dividir por cero.\n-- De las que se sabe si renovaron, qué parte renovó después de su año de creación\nSELECT formation_year AS anio,\n sum(cohort_size) AS cohorte,\n round(100.0 * sum(renewed) / nullif(sum(renewed) + sum(never_renewed), 0), 1) AS pct_renovo\nFROM survival\nWHERE formation_year BETWEEN 2000 AND 2025\nGROUP BY formation_year ORDER BY formation_year;"],
294
+ ["Unir tablas", "Las actividades que más empresas crean",
295
+ "-- JOIN trae la descripción de cada código desde otra tabla\nSELECT a.clase, a.descripcion, count(*) AS creadas_2025\nFROM companies c JOIN activities a ON a.clase = c.sector_code\nWHERE c.formation_year = 2025\nGROUP BY ALL ORDER BY creadas_2025 DESC\nLIMIT 20;"],
296
+ ["Unir tablas", "Empresas creadas por día en cada gobierno",
297
+ "-- Una unión por rango de fechas, no por igualdad.\n-- El ritmo diario, porque los periodos no duran lo mismo; el actual va corriendo\nSELECT p.periodo, p.presidente,\n count(*) AS creadas,\n round(count(*) / (coalesce(p.fin, current_date) - p.inicio), 0) AS por_dia\nFROM periods p\nJOIN companies c ON c.first_registered >= p.inicio\n AND c.first_registered < coalesce(p.fin, current_date)\nGROUP BY p.periodo, p.presidente, p.inicio, p.fin\nORDER BY p.inicio;"],
298
+ ["Unir tablas", "Empresas vigentes por cada mil habitantes",
299
+ "-- WITH nombra consultas intermedias. regions traduce cámaras y departamentos a una\n-- misma región, para poder cruzar empresas con la población del DANE\nWITH empresas AS (\n SELECT r.region, sum(c.live_companies) AS vigentes\n FROM cohort c JOIN regions r ON r.tipo = 'camara' AND r.codigo = c.codigo_camara\n WHERE c.is_juridical\n GROUP BY r.region\n), habitantes AS (\n SELECT r.region, sum(p.poblacion) AS poblacion\n FROM population p JOIN regions r ON r.tipo = 'departamento' AND r.codigo = p.dp\n WHERE p.anio = 2026\n GROUP BY r.region\n)\nSELECT region, vigentes, poblacion,\n round(1000.0 * vigentes / poblacion, 1) AS por_mil_habitantes\nFROM empresas JOIN habitantes USING (region)\nORDER BY por_mil_habitantes DESC;"],
300
+ ["Unir tablas", "Las cámaras que comparten más empresas",
301
+ "-- La misma tabla unida dos veces, una por cada lado de la pareja.\n-- Una empresa inscrita en dos cámaras: casi siempre un traslado de domicilio\nWITH camaras AS (SELECT DISTINCT codigo_camara, chamber FROM cohort)\nSELECT a.chamber AS camara_a, b.chamber AS camara_b,\n l.empresas, l.con_traslado, l.misma_fecha\nFROM chamber_links l\nJOIN camaras a ON a.codigo_camara = l.camara_a\nJOIN camaras b ON b.codigo_camara = l.camara_b\nORDER BY l.empresas DESC\nLIMIT 15;"],
302
+
303
+ ["Fechas", "Sociedades y comerciantes nuevos, mes a mes",
304
+ "-- Una subconsulta halla el último mes con datos; INTERVAL resta 24 meses.\n-- El último mes suele ir incompleto\nSELECT mes,\n sum(matriculas) FILTER (WHERE org_type = 'sociedad') AS sociedades,\n sum(matriculas) FILTER (WHERE org_type = 'persona natural') AS personas_naturales\nFROM registry_months\nWHERE evento = 'matrícula'\n AND mes > (SELECT max(mes) FROM registry_months) - INTERVAL 24 MONTH\nGROUP BY mes ORDER BY mes;"],
305
+ ["Fechas", "¿Qué día de la semana se crean las sociedades?",
306
+ "-- isodow() numera los días de lunes (1) a domingo (7); una lista los nombra\nSELECT isodow(fecha) AS n,\n ['lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado', 'domingo'][isodow(fecha)] AS dia,\n sum(matriculas) AS sociedades_inscritas\nFROM registry_events\nWHERE evento = 'matrícula' AND org_type = 'sociedad' AND fecha >= DATE '2015-01-01'\nGROUP BY ALL ORDER BY n;"],
307
+ ["Fechas", "La pandemia, mes a mes (PIVOT)",
308
+ "-- PIVOT convierte los valores de una columna, aquí los años, en columnas\nPIVOT (\n SELECT month(mes) AS mes, year(mes) AS anio, matriculas\n FROM registry_months\n WHERE evento = 'matrícula' AND org_type = 'sociedad' AND year(mes) BETWEEN 2019 AND 2021\n) ON anio USING sum(matriculas)\nGROUP BY mes ORDER BY mes;"],
309
+
310
+ ["Funciones de ventana", "Cuánto cambian las creaciones de un año al otro",
311
+ "-- lag() trae el valor de la fila anterior, en el orden que dice OVER\nWITH anual AS (\n SELECT formation_year AS anio, sum(companies) AS creadas\n FROM cohort\n WHERE is_juridical AND formation_year BETWEEN 2000 AND 2025\n GROUP BY formation_year\n)\nSELECT anio, creadas,\n lag(creadas) OVER (ORDER BY anio) AS el_anio_anterior,\n round(100.0 * (creadas / lag(creadas) OVER (ORDER BY anio) - 1), 1) AS cambio_pct\nFROM anual ORDER BY anio;"],
312
+ ["Funciones de ventana", "Los tres sectores más grandes de cada cámara",
313
+ "-- row_number() numera dentro de cada cámara (PARTITION BY);\n-- QUALIFY filtra por ese número, como un WHERE para las ventanas\nSELECT chamber AS camara, section_label AS sector,\n sum(live_companies) AS vigentes,\n row_number() OVER (PARTITION BY chamber ORDER BY sum(live_companies) DESC) AS puesto\nFROM cohort\nWHERE is_juridical AND section IS NOT NULL\nGROUP BY chamber, section_label\nQUALIFY puesto <= 3\nORDER BY camara, puesto;"],
314
+ ["Funciones de ventana", "¿Qué tan jóvenes son las empresas vigentes?",
315
+ "-- sum() OVER (ORDER BY ...) acumula fila a fila; sum() OVER () es el total.\n-- La fila donde el acumulado pasa del 50 % parte a las vigentes en dos mitades\nWITH anual AS (\n SELECT formation_year AS anio, sum(live_companies) AS vigentes\n FROM cohort WHERE is_juridical GROUP BY formation_year\n), acumulado AS (\n SELECT anio, vigentes,\n round(100.0 * sum(vigentes) OVER (ORDER BY anio DESC) / sum(vigentes) OVER (), 1) AS pct_creadas_desde_ese_anio\n FROM anual\n)\nSELECT * FROM acumulado WHERE anio >= 1990 ORDER BY anio DESC;"],
316
+
317
+ ["Estados financieros", "Las empresas que más venden",
318
+ "-- Solo las empresas que reportan a la Superintendencia de Sociedades.\n-- Las cifras vienen en miles de pesos: entre 1e6 da miles de millones\nSELECT c.display_name AS razon_social, c.chamber AS camara, f.anio_fiscal,\n round(f.ingresos / 1e6, 1) AS ingresos_miles_de_millones\nFROM financials f JOIN companies c USING (nit)\nWHERE f.anio_fiscal = (SELECT max(anio_fiscal) FROM financials)\nORDER BY f.ingresos DESC NULLS LAST\nLIMIT 25;"],
319
+ ["Estados financieros", "¿Qué edad tienen las empresas que reportan?",
320
+ "-- LEFT JOIN conserva las que no tienen pareja: su nit queda en NULL\nSELECT CASE WHEN f.nit IS NULL THEN 'no reportan' ELSE 'reportan' END AS grupo,\n count(*) AS sociedades,\n median(year(current_date) - c.formation_year) AS edad_mediana\nFROM companies c\nLEFT JOIN (SELECT DISTINCT nit FROM financials\n WHERE anio_fiscal = (SELECT max(anio_fiscal) FROM financials)) f USING (nit)\nWHERE c.is_live AND c.org_type = 'sociedad'\nGROUP BY ALL;"],
321
+ ["Estados financieros", "El margen típico de cada sector",
322
+ "-- median() resiste a los valores extremos mejor que avg().\n-- Se dejan fuera los años marcados con una unidad dudosa\nSELECT s.label AS sector, count(*) AS empresas,\n round(100 * median(f.ganancia / f.ingresos), 1) AS margen_neto_mediano_pct\nFROM financials f\nJOIN companies c USING (nit)\nJOIN sector_sections s ON s.section = c.section\nWHERE f.anio_fiscal = 2024 AND f.ingresos > 0 AND NOT f.unidad_dudosa\nGROUP BY s.label\nHAVING count(*) >= 50\nORDER BY margen_neto_mediano_pct DESC;"],
323
+ ["Estados financieros", "¿Cuánto deben las empresas? Cuartiles por año",
324
+ "-- quantile_cont() da cualquier percentil: aquí el 25 %, la mediana y el 75 %.\n-- Mira siempre con cuántas empresas se calcula cada cifra: si son pocas, pesa poco\nSELECT anio_fiscal, count(*) AS empresas,\n round(quantile_cont(pasivos / activos, 0.25), 2) AS p25,\n round(quantile_cont(pasivos / activos, 0.50), 2) AS mediana,\n round(quantile_cont(pasivos / activos, 0.75), 2) AS p75\nFROM financials\nWHERE activos > 0 AND NOT unidad_dudosa\nGROUP BY anio_fiscal ORDER BY anio_fiscal;"],
325
+ ["Estados financieros", "Las grandes que más crecieron, descontando la inflación",
326
+ "-- La misma tabla dos veces, un año en cada lado; prices lleva los pesos de 2024 a\n-- pesos de 2025 con el IPC promedio. Grandes: más de 100.000 millones en 2024\nWITH f AS (SELECT nit, anio_fiscal, ingresos FROM financials WHERE NOT unidad_dudosa AND ingresos > 0),\nipc AS (SELECT max(ipc_promedio) FILTER (WHERE anio = 2025) / max(ipc_promedio) FILTER (WHERE anio = 2024) AS factor FROM prices)\nSELECT c.display_name AS razon_social,\n round(a.ingresos / 1e6, 1) AS ingresos_2024_miles_de_millones,\n round(b.ingresos / 1e6, 1) AS ingresos_2025_miles_de_millones,\n round(100 * (b.ingresos / (a.ingresos * ipc.factor) - 1), 1) AS crecimiento_real_pct\nFROM f a\nJOIN f b ON b.nit = a.nit AND b.anio_fiscal = 2025\nCROSS JOIN ipc\nJOIN companies c ON c.nit = a.nit\nWHERE a.anio_fiscal = 2024 AND a.ingresos >= 100000000\nORDER BY crecimiento_real_pct DESC\nLIMIT 20;"],
327
+
328
+ ["Cómo se hizo", "De 8,3 millones a la lista objetivo",
329
+ "-- Cada paso que lleva del registro completo a las empresas que se analizan\nSELECT step AS paso, label, remaining AS quedan, lost_here AS salen, predicate\nFROM funnel ORDER BY step;"],
330
+ ["Cómo se hizo", "¿Coinciden las cifras con las de Supersociedades?",
331
+ "-- Los estados financieros publicados aquí, comparados empresa por empresa\n-- con los de la Superintendencia de Sociedades\nSELECT anio, campo, comparadas,\n round(100.0 * coinciden / comparadas, 1) AS pct_iguales,\n round(100.0 * dentro_5 / comparadas, 1) AS pct_a_menos_de_5\nFROM supersociedades\nORDER BY anio, campo;"],
332
  ];
333
 
334
  const $ = (id) => document.getElementById(id);
 
404
  // companies.parquet from Hugging Face before the first query (measured
405
  // 2026-10-14). With ranges it reads the footer and the columns it needs.
406
  await db.open({ query: { castDecimalToDouble: true }, filesystem: { forceFullHTTPReads: false } });
407
+ const c = await db.connect();
408
+ // One read per row group for every column a query needs, rather than one per
409
+ // column: a third fewer requests over `companies` (measured 2026-10-16).
410
+ await c.query("SET prefetch_all_parquet_files = true");
411
+ return c;
412
  }
413
 
414
  // One view per published file, named after it. The list comes from the
415
  // manifest, so a table added to the edition appears here without an edit.
416
+ //
417
+ // Each view reads the file's final address, not Hugging Face's. Hugging Face
418
+ // answers every request with a redirect to its CDN, and DuckDB reads a file in
419
+ // one request per row group and column, one after another: a query over
420
+ // `companies` made up to 360 requests, half of them redirects, and took up to a
421
+ // minute on a fast connection (measured 2026-10-16). The CDN's address is
422
+ // signed and expires within the hour, so it is looked up again before that,
423
+ // and once more if a read is refused.
424
+ const RESOLVE_EVERY = 30 * 60 * 1000;
425
+ let viewNames = [], resolvedAt = 0;
426
+ async function finalUrl(n) {
427
+ const url = `${BASE}/data/${n}.parquet`;
428
+ try {
429
+ const r = await fetch(url, { method: "HEAD", cache: "no-store" });
430
+ if (r.ok && r.url) return r.url;
431
+ } catch {}
432
+ return url;
433
+ }
434
  async function createViews(m) {
435
+ if (m) viewNames = Object.keys(m.files).map((f) => f.replace(/\.parquet$/, ""));
436
+ const urls = await Promise.all(viewNames.map(finalUrl));
437
+ for (const [i, n] of viewNames.entries()) {
438
+ await conn.query(`CREATE OR REPLACE VIEW ${n} AS SELECT * FROM read_parquet('${urls[i].replace(/'/g, "''")}')`);
439
  }
440
+ resolvedAt = Date.now();
441
+ return viewNames;
442
  }
443
 
444
  async function renderSchema(m, names) {
 
507
  return { text: String(v), cls: "" };
508
  }
509
 
510
+ // Each run gets a number. Queries on one connection run in turn, so picking a
511
+ // second example while the first is still reading would otherwise show the
512
+ // first's result last, under the second's SQL.
513
+ let runs = 0;
514
  async function run() {
515
  const sql = $("sql").value;
516
  if (!sql.trim() || !conn) return;
517
+ const me = ++runs;
518
  $("run").disabled = true;
519
  const t0 = performance.now();
520
  const tick = setInterval(() => setStatus("busy", `Ejecutando… ${((performance.now() - t0) / 1000).toLocaleString("es-CO", { maximumFractionDigits: 1, minimumFractionDigits: 1 })} s`), 200);
521
  setStatus("busy", "Ejecutando…");
522
  try {
523
  const q = displayable(sql);
524
+ if (Date.now() - resolvedAt > RESOLVE_EVERY) await createViews();
525
+ let res;
526
+ try { res = await conn.query(q.sql); }
527
+ catch (e) {
528
+ if (!/HTTP|40[13]|expired/i.test(String(e.message || e))) throw e;
529
+ await createViews();
530
+ res = await conn.query(q.sql);
531
+ }
532
+ if (me !== runs) return;
533
  const secs = ((performance.now() - t0) / 1000).toFixed(2);
534
  const fields = res.schema.fields;
535
  let rows = res.toArray().map((r) => r.toJSON());
 
541
  $("csv").disabled = rows.length === 0;
542
  history.replaceState(null, "", `#q=${encodeURIComponent(sql)}`);
543
  } catch (e) {
544
+ if (me !== runs) return;
545
  lastRows = null;
546
  $("results").replaceChildren(el("div", { class: "msg err", text: String(e.message || e) }));
547
  setStatus("err", "Error en la consulta");
548
  $("csv").disabled = true;
549
  } finally {
550
  clearInterval(tick);
551
+ if (me === runs) $("run").disabled = false;
552
  }
553
  }
554
 
 
598
  }
599
 
600
  // ---------------------------------------------------------------- wiring
601
+ const groups = new Map();
602
+ for (const [i, [group, title]] of EXAMPLES.entries()) {
603
+ if (!groups.has(group)) $("examples").append(groups.set(group, el("optgroup", { label: group })).get(group));
604
+ groups.get(group).append(el("option", { value: String(i), text: title }));
605
+ }
606
  $("examples").addEventListener("change", (e) => {
607
  const ex = EXAMPLES[Number(e.target.value)];
608
+ if (ex) { $("sql").value = ex[2]; run(); }
609
  e.target.value = "";
610
  });
611
  $("run").addEventListener("click", run);
 
617
  });
618
 
619
  const fromLink = new URLSearchParams(location.hash.slice(1)).get("q");
620
+ $("sql").value = fromLink || EXAMPLES[0][2];
621
 
622
  (async () => {
623
  try {