Spaces:
Running
Running
Download explorar/index.html from clyxconsultingdev/amc-console: direct link, hf CLI and curl.
- Browser
- Download file 44.5 kB
-
https://huggingface.co/spaces/clyxconsultingdev/amc-console/resolve/main/explorar/index.html
- Command line
-
hf download hf://spaces/clyxconsultingdev/amc-console/explorar/index.html
-
curl -L -o index.html https://huggingface.co/spaces/clyxconsultingdev/amc-console/resolve/main/explorar/index.html
44.5 kB
| <html lang="es" data-root="../"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Explora una actividad · AMC</title> | |
| <meta name="description" content="Escoge una actividad económica y una región de Colombia: cuántas empresas están activas, cuántas se crean cada año, cuántas siguen y cuáles son las más grandes. Sin escribir SQL."> | |
| <meta property="og:title" content="Explora una actividad económica en Colombia"> | |
| <meta property="og:description" content="Cuántas empresas activas hay en cada actividad y región, cuántas se crean cada año y cuáles venden más, con los datos abiertos del registro."> | |
| <meta property="og:type" content="website"> | |
| <link rel="icon" href="../assets/favicon.svg" type="image/svg+xml"> | |
| <meta property="og:image" content="https://amc.clyxconsulting.com/assets/og/inicio.png"> | |
| <meta property="og:image:width" content="1200"> | |
| <meta property="og:image:height" content="630"> | |
| <meta property="og:site_name" content="Analíticas Mercantiles Colombianas"> | |
| <meta name="twitter:card" content="summary_large_image"> | |
| <meta name="twitter:image" content="https://amc.clyxconsulting.com/assets/og/inicio.png"> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Inter+Tight:wght@600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> | |
| <link rel="stylesheet" href="../assets/amc.css"> | |
| <link rel="stylesheet" href="../assets/nav.css"> | |
| <style> | |
| .pick { display: grid; gap: 12px; margin: 26px 0 0; max-width: 820px; } | |
| .pick .act { position: relative; } | |
| .pick input { width: 100%; box-sizing: border-box; font: 500 18px var(--sans); color: var(--ink); background: var(--surface); | |
| border: 1px solid var(--border-strong); border-radius: 12px; padding: 14px 16px; box-shadow: var(--shadow); } | |
| .pick input:focus { outline: 2px solid var(--c1); outline-offset: 1px; } | |
| .pick .row { display: flex; flex-wrap: wrap; gap: 12px 18px; align-items: flex-end; } | |
| .pick .fld { display: grid; gap: 6px; } | |
| .pick .lbl { font-size: 12.5px; font-weight: 600; color: var(--ink-2); letter-spacing: .01em; } | |
| .sugg button[aria-selected="true"] { border-color: var(--c1); box-shadow: var(--shadow); } | |
| .quick-wrap { display: grid; gap: 8px; margin: 6px 0 0; } | |
| .quick .chip { font-size: 13.5px; padding: 7px 13px; } | |
| .quick .chip[aria-current="true"] { background: var(--ink); color: var(--page); border-color: var(--ink); } | |
| @media (max-width: 560px) { | |
| .pick .row { display: grid; grid-template-columns: minmax(0, 1fr); } | |
| .pick .row .sel { width: 100%; } | |
| .pick .seg-ctl { display: flex; width: 100%; box-sizing: border-box; } | |
| .pick .seg-ctl button { flex: 1 1 0; padding: 9px 6px; } | |
| .pick input { font-size: 16px; } | |
| } | |
| .sugg { display: grid; gap: 6px; margin: 8px 0 0; } | |
| .sugg button { display: flex; gap: 12px; align-items: baseline; justify-content: space-between; text-align: left; width: 100%; | |
| background: var(--surface); border: 1px solid var(--border); border-radius: 11px; padding: 10px 14px; | |
| font: inherit; color: inherit; cursor: pointer; } | |
| .sugg button:hover, .sugg button:focus-visible { border-color: var(--border-strong); box-shadow: var(--shadow); } | |
| .sugg .code { font: 500 12.5px var(--mono); color: var(--muted); margin-right: 8px; } | |
| .sugg .n { color: var(--ink-2); font-size: 13px; white-space: nowrap; } | |
| .quick { display: flex; flex-wrap: wrap; gap: 8px; } | |
| .quick button { cursor: pointer; border: 1px solid var(--border); } | |
| .quick button:hover { border-color: var(--border-strong); } | |
| .status { color: var(--muted); font-size: 13.5px; margin: 4px 0 0; min-height: 20px; } | |
| .chosen { margin: 34px 0 0; scroll-margin-top: 80px; } | |
| .chosen h2 { font: 800 clamp(26px, 4vw, 40px)/1.1 var(--display); letter-spacing: -.03em; margin: 6px 0 0; } | |
| .facts.x4 { grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 22px 0 0; } | |
| .resumen { margin: 26px 0 0; } | |
| .resumen h3 { margin: 0 0 8px; } | |
| .resumen ul, .resumen > p { max-width: 820px; } | |
| .resumen ul { margin: 0; padding-left: 1.2em; color: var(--ink-2); } | |
| .resumen li { margin: 0 0 6px; } | |
| .resumen li b { color: var(--ink); } | |
| .resumen .facts.x4 { margin-top: 14px; } | |
| @media (max-width: 960px) { .facts.x4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } } | |
| @media (max-width: 560px) { .facts.x4 { grid-template-columns: minmax(0, 1fr); } } | |
| .facts.x4 .fact .big { font-size: clamp(30px, 3.6vw, 40px); } | |
| .firms { display: grid; gap: 6px; } | |
| .firm { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 4px 12px; align-items: baseline; | |
| padding: 10px 2px; border-bottom: 1px solid var(--border); } | |
| .firm .k { color: var(--muted); font-variant-numeric: tabular-nums; } | |
| /* A registered name can carry its clauses ("… Y PODRÁ UTILIZAR LA DENOMINACIÓN | |
| ABREVIADA …", 309 characters): three lines at most here, the whole name on | |
| its profile, a tap away, and for screen readers, which still read it all. */ | |
| .firm a { font-weight: 600; text-decoration: none; overflow-wrap: anywhere; | |
| display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3; overflow: hidden; } | |
| .firm a:hover { text-decoration: underline; } | |
| .firm .v { font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right; } | |
| .firm .s { grid-column: 2 / 4; color: var(--ink-2); font-size: 13px; } | |
| /* On a phone a figure like "438,6 mil millones de pesos" took most of the | |
| row and left the name a few letters a line: there, it goes under the name. */ | |
| @media (max-width: 560px) { | |
| .firm { grid-template-columns: 28px minmax(0, 1fr); gap: 2px 10px; } | |
| .firm .v { grid-column: 2; text-align: left; white-space: normal; } | |
| .firm .s { grid-column: 2; } | |
| } | |
| </style> | |
| <link rel="canonical" href="https://amc.clyxconsulting.com/explorar/"> | |
| <meta property="og:url" content="https://amc.clyxconsulting.com/explorar/"> | |
| <meta property="og:locale" content="es_CO"> | |
| <meta property="og:image:type" content="image/png"> | |
| <meta property="og:image:alt" content="Explora una actividad económica en Colombia"> | |
| </head> | |
| <body> | |
| <main class="wrap" id="app"><p class="loading">Preparando el explorador: la primera vez descarga los datos y el motor de consulta, unos segundos.</p></main> | |
| <noscript><p class="wrap">Esta página necesita JavaScript.</p></noscript> | |
| <script type="module"> | |
| import * as A from "../assets/amc.js"; | |
| import { columns, barList, d3 } from "../assets/charts.js"; | |
| const { el, n, pct, pct1, rich, figure, readThis, tableView, money } = A; | |
| // A share small enough to round to 0 % says so instead. | |
| const share = (x) => x >= 0.1 ? pct(x) : x >= 0.001 ? pct1(x) : x > 0 ? "< 0,1 %" : pct(0); | |
| A.chrome("explorar"); | |
| const app = document.getElementById("app"); | |
| const norm = (t) => t.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9ñ ]/g, " ").replace(/\s+/g, " ").trim(); | |
| // Everyday words the official names do not use: "software" is not in 6201's | |
| // name, nor "restaurante" in 5611's. Added to what a search matches, never shown. | |
| const ALIAS = { | |
| "6201": "software programacion desarrollo apps aplicaciones", "6202": "software consultoria informatica sistemas", | |
| "5820": "software", "5611": "restaurante restaurantes comida", "5613": "cafe cafeteria", "5619": "comidas rapidas", | |
| "4711": "tienda tiendas minimercado supermercado", "4773": "drogueria farmacia", "6910": "abogados abogado juridica", | |
| "6920": "contador contadores contabilidad", "8621": "medico consultorio", "8622": "odontologo odontologia", | |
| "9602": "salon de belleza barberia", "4923": "transporte de carga camiones", "4921": "taxi transporte de pasajeros", | |
| "7310": "agencia de publicidad marketing", "4111": "construccion de vivienda", "6810": "inmobiliaria finca raiz", | |
| "1081": "panaderia pan", "4631": "mayorista alimentos", "5511": "hotel hoteles", "7911": "agencia de viajes turismo", | |
| }; | |
| const MES = ["ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic"]; | |
| // One-tap starts in everyday words (audit of the controls, 2026-10-20): the | |
| // top classes by name were truncated CIIU titles ("Comercio al por menor en…"). | |
| const STARTS = [["todas", "Todas las actividades"], ["5611", "Restaurantes"], ["4711", "Tiendas de barrio"], | |
| ["9602", "Peluquerías"], ["1081", "Panaderías"], ["6201", "Software"], ["F", "Construcción"], ["H", "Transporte"]]; | |
| const TIPOS = [["todas", "Todas"], ["juridicas", "Sociedades y entidades"], ["naturales", "Personas naturales"]]; | |
| try { | |
| // The engine and the explorer's seven files (about 3 MB) start at once, | |
| // beside the summary, and every question after is answered in memory. | |
| const ready = import("../assets/duck.js").then(async (d) => { | |
| await d.preload(["explorer", "explorer_months", "explorer_firms", "activities", "sector_sections", "jurisdicciones", "municipal_population"]); | |
| return d; | |
| }); | |
| const s = await A.summary(); | |
| const Y = s.year; | |
| const d = await ready; | |
| // Contracts with the State (D33), when the edition has them: two more files | |
| // (2.8 MB), fetched beside the first answer, which does not wait for them. | |
| const KFILES = ["explorer_contracts", "explorer_contractors"]; | |
| const secop = d.connect().then(({ files }) => KFILES.every((k) => files.some(([f]) => f === k)) | |
| ? d.preload(KFILES).then(() => true) : false).catch(() => false); | |
| // DIAN's large taxpayers, when the edition has the list: one small file | |
| // (about 20 kB), fetched the same way. | |
| const gc = d.connect().then(({ files }) => files.some(([f]) => f === "large_taxpayers") | |
| ? d.preload(["large_taxpayers"]).then(() => true) : false).catch(() => false); | |
| // The activities with their live companies, and the chambers: what the | |
| // pickers offer. The newest fiscal year sets which revenue is current. | |
| const [acts, cams, [{ fy }]] = await Promise.all([ | |
| d.query(`SELECT e.sector_code AS clase, arg_max(a.descripcion, a.edicion) AS nombre, sum(e.activas) AS activas | |
| FROM explorer e JOIN activities a ON a.clase = e.sector_code GROUP BY 1 ORDER BY 3 DESC`), | |
| d.query(`SELECT codigo_camara, any_value(chamber) AS chamber, sum(activas) AS activas | |
| FROM explorer WHERE codigo_camara IS NOT NULL GROUP BY 1 ORDER BY 2`), | |
| d.query(`SELECT max(anio_fiscal) AS fy FROM explorer_firms`)]); | |
| const byCode = new Map(acts.map((a) => [a.clase, a])); | |
| const camName = new Map(cams.map((c) => [c.codigo_camara, A.title(c.chamber)])); | |
| const allLive = cams.reduce((t, c) => t + c.activas, 0); | |
| for (const a of acts) { a.kind = "clase"; a.key = norm(`${a.clase} ${a.nombre} ${ALIAS[a.clase] || ""}`); } | |
| // Besides a class: a whole CIIU section (its divisions' valid classes, as the | |
| // rest of the site counts a section) and every activity at once, so "how | |
| // many construction companies" or "how many companies per inhabitant" need | |
| // no single class (audit tasks 11 and 3). | |
| const secs = (await d.query(`SELECT section, divisions, label FROM sector_sections ORDER BY section`)).map((r) => { | |
| const [lo, hi = lo] = r.divisions.split("-"); | |
| const activas = acts.filter((x) => x.clase.slice(0, 2) >= lo && x.clase.slice(0, 2) <= hi).reduce((s, x) => s + x.activas, 0); | |
| return { kind: "seccion", clase: r.section, lo, hi, nombre: `${r.label} (toda la sección)`, activas, | |
| key: norm(`seccion ${r.section} ${r.label}`) }; | |
| }); | |
| const todas = { kind: "todas", clase: "todas", nombre: "Todas las actividades", activas: allLive, key: norm("todas las actividades empresas total") }; | |
| acts.unshift(todas, ...secs); | |
| for (const a of [todas, ...secs]) byCode.set(a.clase, a); | |
| const actCond = (a) => a.kind === "clase" ? `sector_code = ${d.lit(a.clase)}` | |
| : a.kind === "seccion" ? `sector_code IN (SELECT clase FROM activities WHERE left(clase, 2) BETWEEN '${a.lo}' AND '${a.hi}')` : null; | |
| const h = new URLSearchParams(location.hash.slice(1)); | |
| const st = { clase: byCode.has(h.get("clase")) ? h.get("clase") : null, | |
| camara: camName.has(h.get("camara")) ? h.get("camara") : "", | |
| tipo: TIPOS.some(([k]) => k === h.get("tipo")) ? h.get("tipo") : "todas" }; | |
| // A combobox (WAI-ARIA 1.2): focus stays in the input, arrows move through | |
| // the suggestions, Enter picks, Escape closes. | |
| const input = el("input", { id: "act-input", type: "search", role: "combobox", "aria-autocomplete": "list", "aria-expanded": "false", | |
| "aria-controls": "act-list", placeholder: "Ej.: restaurante, software, 4711", autocomplete: "off" }); | |
| const sugg = el("div", { class: "sugg", id: "act-list", role: "listbox", "aria-label": "Actividades que coinciden" }); | |
| const sel = el("select", { class: "sel", id: "reg-select" }, el("option", { value: "", text: "Todo el país" }), | |
| ...cams.map((c) => el("option", { value: c.codigo_camara, text: camName.get(c.codigo_camara) }))); | |
| sel.value = st.camara; | |
| const seg = el("div", { class: "seg-ctl", role: "group", "aria-labelledby": "tipo-lbl" }, | |
| ...TIPOS.map(([k, label]) => el("button", { type: "button", "data-k": k, "aria-pressed": String(k === st.tipo), text: label }))); | |
| const quick = el("div", { class: "quick", role: "group", "aria-labelledby": "quick-lbl" }, | |
| ...STARTS.filter(([c]) => byCode.has(c)).map(([c, label]) => el("button", { type: "button", class: "chip", "data-clase": c, text: label }))); | |
| const markQuick = () => { for (const b of quick.children) b.setAttribute("aria-current", String(b.dataset.clase === st.clase)); }; | |
| const out = el("section", { class: "chosen", "aria-live": "polite" }); | |
| app.replaceChildren( | |
| el("header", { class: "story-head", style: "padding-bottom:0" }, | |
| el("p", { class: "kicker", text: "Herramienta · Actividades" }), | |
| el("h1", { text: "Explora una actividad" }), | |
| el("p", { class: "dek", text: "Escoge a qué se dedican las empresas y dónde: cuántas están activas, cuántas se crean cada año, cuántas de cada año siguen, cuáles venden más y cuánto le venden al Estado. Sin escribir SQL." }), | |
| el("div", { class: "pick" }, | |
| el("div", { class: "fld act" }, el("label", { class: "lbl", for: "act-input", text: "Actividad" }), input, sugg), | |
| el("div", { class: "quick-wrap" }, el("span", { class: "lbl", id: "quick-lbl", text: "O empieza por una de estas" }), quick), | |
| el("div", { class: "row" }, | |
| el("div", { class: "fld" }, el("label", { class: "lbl", for: "reg-select", text: "Región" }), sel), | |
| el("div", { class: "fld" }, el("span", { class: "lbl", id: "tipo-lbl", text: "Tipo de empresa" }), seg)))), | |
| out, | |
| readThis(["La actividad es la ", { b: "principal" }, " que la empresa declaró en el registro, con la clasificación CIIU del DANE. Una empresa que hace varias cosas cuenta en una sola."], | |
| ["La región es la ", { b: "cámara de comercio" }, " donde está la matrícula: la jurisdicción del registro, no necesariamente donde opera. El registro abierto no trae el municipio."], | |
| [{ b: "Activa" }, ` quiere decir que renovó su matrícula en ${Y}, o que la abrió ese año. Las creadas en ${Y} van hasta el ${A.date(s.data_through)}.`], | |
| ["Los ingresos son los que la empresa reportó a la Superintendencia de Sociedades. Los bancos, las aseguradoras y las sociedades que cotizan en bolsa reportan a la Superintendencia Financiera y no aparecen."], | |
| ["Los ", { b: "contratos con el Estado" }, " son los de SECOP I y II de las empresas que el registro nombra, por la actividad y la cámara de la empresa, no por dónde se ejecutan. Su valor deja fuera los contratos sin fecha de firma, los de años anteriores a la serie del IPC y los valores imposibles, cuenta una vez cada contrato compartido entre varios proveedores y está en pesos constantes, con el IPC del DANE."])); | |
| A.footer(s, { resolution: "todas" }); | |
| A.reveal(app); | |
| let active = -1; | |
| const opts = () => [...sugg.querySelectorAll("[role=option]")]; | |
| const closeSugg = () => { sugg.replaceChildren(); active = -1; input.setAttribute("aria-expanded", "false"); input.removeAttribute("aria-activedescendant"); }; | |
| const setActive = (i) => { | |
| const o = opts(); if (!o.length) return; | |
| active = (i + o.length) % o.length; | |
| o.forEach((x, k) => x.setAttribute("aria-selected", String(k === active))); | |
| input.setAttribute("aria-activedescendant", o[active].id); | |
| o[active].scrollIntoView({ block: "nearest" }); | |
| }; | |
| function showSugg() { | |
| const q = norm(input.value); | |
| closeSugg(); | |
| if (q.length < 2) return; | |
| const words = q.split(" "); | |
| const hits = acts.filter((a) => /^\d+$/.test(q) ? a.kind === "clase" && a.clase.startsWith(q) : words.every((w) => a.key.includes(w))).slice(0, 8); | |
| if (!hits.length) { sugg.append(el("p", { class: "status", role: "status", text: "Ninguna actividad tiene esas palabras. Prueba con otra, o con el código CIIU." })); return; } | |
| hits.forEach((a, i) => | |
| sugg.append(el("button", { type: "button", role: "option", id: `act-opt-${i}`, tabindex: "-1", "aria-selected": "false", "data-clase": a.clase }, | |
| el("span", {}, el("span", { class: "code", text: a.kind === "todas" ? "*" : a.clase }), a.nombre), el("span", { class: "n", text: `${n(a.activas)} activas` })))); | |
| input.setAttribute("aria-expanded", "true"); | |
| } | |
| const choose = (clase) => { st.clase = clase; input.value = byCode.get(clase).nombre; closeSugg(); markQuick(); render(true); }; | |
| input.addEventListener("input", showSugg); | |
| input.addEventListener("keydown", (e) => { | |
| if (e.key === "ArrowDown") { e.preventDefault(); if (!opts().length) showSugg(); setActive(active + 1); } | |
| else if (e.key === "ArrowUp") { e.preventDefault(); setActive(active - 1); } | |
| else if (e.key === "Enter") { e.preventDefault(); (opts()[active] || opts()[0])?.click(); } | |
| else if (e.key === "Escape") { closeSugg(); } | |
| }); | |
| input.addEventListener("blur", () => setTimeout(() => { if (!sugg.contains(document.activeElement)) closeSugg(); }, 150)); | |
| for (const box of [sugg, quick]) box.addEventListener("click", (e) => { const b = e.target.closest("button[data-clase]"); if (b) choose(b.dataset.clase); }); | |
| sel.addEventListener("change", () => { st.camara = sel.value; render(); }); | |
| seg.addEventListener("click", (e) => { | |
| const b = e.target.closest("button[data-k]"); if (!b) return; | |
| st.tipo = b.dataset.k; | |
| for (const x of seg.children) x.setAttribute("aria-pressed", String(x === b)); | |
| render(); | |
| }); | |
| // Contracts with the State for a choice (explorer_contracts and | |
| // explorer_contractors): by year of signing in constant pesos, and the ten | |
| // companies with the most value. Whole pesos, so money() gets thousands. | |
| const pesos = (v) => (Math.abs(v) >= 1e6 ? `${money(v / 1e3)} de pesos` : `${n(v)} pesos`); | |
| const KSOURCE = "SECOP I y II, Agencia Nacional de Contratación Pública – Colombia Compra Eficiente (datos.gov.co); RUES · Confecámaras; DANE (IPC). Cálculos de AMC."; | |
| function contractFigures(years, firms, sqlK, sqlKF, place, whose) { | |
| const total = years.reduce((t, r) => t + r.contratos, 0); | |
| const f = figure({ title: "Contratos con el Estado", | |
| sub: `Los que firmaron en SECOP I y II ${whose} matriculadas en ${place}, por año de firma. Solo las empresas que el registro nombra: los contratos con personas, consorcios y otros proveedores se cuentan por entidad en «A quién le compra el Estado».`, | |
| sql: sqlK, source: KSOURCE }); | |
| if (!total) { | |
| f.body.append(el("p", { class: "lede", text: `Ninguna de ${whose} matriculadas en ${place} tiene contratos en SECOP I o II.` })); | |
| return [f.fig]; | |
| } | |
| const base = years.find((r) => r.pesos_de)?.pesos_de; | |
| const dated = years.filter((r) => r.anio != null), undated = years.find((r) => r.anio == null); | |
| const value = dated.reduce((t, r) => t + (r.valor_constante || 0), 0); | |
| const byYear = new Map(dated.map((r) => [r.anio, r])); | |
| const lastYear = byYear.get(Y - 1)?.empresas ?? 0; | |
| f.body.append(rich("p", { class: "fig-note" }, [{ b: `${n(total)} ${total === 1 ? "contrato" : "contratos"}` }, | |
| undated?.contratos ? ` (${n(undated.contratos)} sin fecha de firma)` : "", ` por ${pesos(value)} de ${base}. En ${Y - 1}, `, | |
| { b: n(lastYear) }, ` ${lastYear === 1 ? "empresa firmó" : "empresas firmaron"} al menos uno. ${Y} va hasta la última descarga de SECOP.`])); | |
| const span = Array.from({ length: 20 }, (_, i) => Y - 19 + i).map((y) => byYear.get(y) || { anio: y, empresas: 0, contratos: 0, valor_constante: 0 }); | |
| const max = Math.max(...span.map((r) => r.valor_constante || 0)); | |
| const [div, unit] = max >= 1e12 ? [1e12, "billones"] : max >= 1e9 ? [1e9, "miles de millones"] : [1e6, "millones"]; | |
| f.body.append(el("p", { class: "fig-note", text: `Valor de los contratos firmados cada año, en ${unit} de pesos de ${base}.` })); | |
| columns(f.body, span.map((r) => ({ label: r.anio % 5 === 0 ? String(r.anio) : "", value: (r.valor_constante || 0) / div, | |
| tip: { title: String(r.anio), rows: [{ label: "Contratos", value: n(r.contratos) }, { label: "Empresas", value: n(r.empresas) }, | |
| { label: "Valor", value: `${pesos(r.valor_constante || 0)} de ${base}` }] } })), | |
| { label: `Valor de los contratos firmados con el Estado por año: ${place}`, labelEvery: () => 1, yFormat: (v) => A.n1(v).replace(/,0$/, "") }); | |
| f.fig.append(tableView(`Contratos con el Estado por año: ${place}`, ["Año", "Contratos", "Empresas", `Valor (millones de pesos de ${base})`], | |
| [...dated].reverse().map((r) => [String(r.anio), n(r.contratos), n(r.empresas), n((r.valor_constante || 0) / 1e6)]) | |
| .concat(undated ? [["Sin fecha", n(undated.contratos), n(undated.empresas), "—"]] : []))); | |
| const g = figure({ title: "Las que más le venden al Estado", wide: false, | |
| sub: `Las diez con más valor en contratos firmados desde el primer año de SECOP, en pesos de ${base}. El nombre abre su ficha en Busca tu empresa.`, | |
| sql: sqlKF, source: KSOURCE }); | |
| g.body.append(el("div", { class: "firms" }, ...firms.map((r, i) => el("div", { class: "firm" }, | |
| el("span", { class: "k", text: String(i + 1) }), | |
| el("a", { href: `../historias/tu-empresa/index.html#nit=${r.nit}`, text: r.display_name }), | |
| el("span", { class: "v", text: r.valor_constante == null ? "—" : pesos(r.valor_constante) }), | |
| el("span", { class: "s", text: `${n(r.contratos)} ${r.contratos === 1 ? "contrato" : "contratos"} con ${n(r.entidades)} ${r.entidades === 1 ? "entidad" : "entidades"}` | |
| + `${r.primero ? `, ${r.primero === r.ultimo ? `en ${r.primero}` : `de ${r.primero} a ${r.ultimo}`}` : ""}${r.is_live ? "" : ` · no renovó en ${Y}`}` }))))); | |
| g.fig.append(tableView(`Las que más le venden al Estado: ${place}`, ["Empresa", "Contratos", "Entidades", "Desde", "Hasta", `Valor (millones de pesos de ${base})`], | |
| firms.map((r) => [r.display_name, n(r.contratos), n(r.entidades), r.primero ? String(r.primero) : "—", r.ultimo ? String(r.ultimo) : "—", | |
| r.valor_constante == null ? "—" : n(r.valor_constante / 1e6)]))); | |
| return [f.fig, g.fig]; | |
| } | |
| let run = 0; | |
| async function render(scroll = false) { | |
| if (!st.clase) return; | |
| const mine = ++run; | |
| history.replaceState(null, "", `#${new URLSearchParams({ clase: st.clase, ...(st.camara && { camara: st.camara }), tipo: st.tipo })}`); | |
| const a = byCode.get(st.clase); | |
| // "Todas" leaves the box empty, so its placeholder still invites typing. | |
| input.value = a.kind === "todas" ? "" : a.nombre; | |
| const conds = [actCond(a), st.tipo !== "todas" && `is_juridical = ${st.tipo === "juridicas"}`].filter(Boolean); | |
| const here = [...conds, st.camara && `codigo_camara = ${d.lit(st.camara)}`].filter(Boolean).join(" AND ") || "TRUE"; | |
| const country = conds.join(" AND ") || "TRUE"; | |
| const inAct = a.kind === "todas" ? "" : " en esta actividad"; | |
| const place = st.camara ? camName.get(st.camara) : "todo el país"; | |
| const who = { todas: "empresas", juridicas: "sociedades y entidades", naturales: "comerciantes personas naturales" }[st.tipo]; | |
| const sqlYears = `SELECT formation_year AS anio, sum(empresas) AS creadas, sum(activas) AS siguen_activas\nFROM explorer\nWHERE ${here} AND formation_year IS NOT NULL\nGROUP BY 1 ORDER BY 1`; | |
| const sqlCams = `SELECT codigo_camara, any_value(chamber) AS camara, sum(activas) AS activas\nFROM explorer\nWHERE ${country}\nGROUP BY codigo_camara HAVING sum(activas) > 0\nORDER BY 3 DESC`; | |
| const sqlFirms = `SELECT display_name, nit, anio_fiscal, ingresos, is_live\nFROM explorer_firms\nWHERE ${[actCond(a), st.camara && `codigo_camara = ${d.lit(st.camara)}`, `anio_fiscal >= ${fy - 1}`].filter(Boolean).join(" AND ")}\nORDER BY ingresos DESC LIMIT 10`; | |
| const sqlMonths = `SELECT mes, sum(empresas) AS creadas\nFROM explorer_months\nWHERE ${here}\nGROUP BY 1 ORDER BY 1`; | |
| // Per inhabitant, by chamber: the people of the municipalities its | |
| // jurisdiction covers (DANE's projection for this year, Decreto 1074 de | |
| // 2015). The 58 jurisdictions cover the country once, so they add up to it. | |
| const sqlChamber = `WITH pob AS (SELECT j.codigo_camara, sum(p.poblacion) AS poblacion | |
| FROM jurisdicciones j JOIN municipal_population p ON p.divipola = j.divipola AND p.anio = ${Y} | |
| GROUP BY 1) | |
| SELECT e.codigo_camara, any_value(e.chamber) AS camara, sum(e.activas) AS activas, any_value(pob.poblacion) AS poblacion, | |
| 100000.0 * sum(e.activas) / any_value(pob.poblacion) AS por_100000 | |
| FROM explorer e JOIN pob USING (codigo_camara) | |
| WHERE ${country} | |
| GROUP BY 1 ORDER BY 5 DESC`; | |
| out.replaceChildren(el("p", { class: "status", text: "Contando…" })); | |
| const [years, byCam, firms, months, perCam] = await Promise.all([d.query(sqlYears), d.query(sqlCams), | |
| st.tipo === "naturales" ? [] : d.query(sqlFirms), d.query(sqlMonths), d.query(sqlChamber)]); | |
| if (mine !== run) return; // a newer choice is already drawing | |
| const y = new Map(years.map((r) => [r.anio, r])); | |
| const live = years.reduce((t, r) => t + r.siguen_activas, 0); | |
| const liveCountry = byCam.reduce((t, r) => t + r.activas, 0); | |
| const made = (k) => y.get(k)?.creadas ?? 0; | |
| const head = el("header", {}, | |
| el("p", { class: "kicker", text: `${a.kind === "clase" ? a.clase : a.kind === "seccion" ? `Sección ${a.clase}` : "Todas las actividades"} · ${place}` }), | |
| el("h2", { text: A.cap(a.nombre) }), | |
| el("div", { class: "meta", style: "margin-top:12px" }, el("span", { class: "chip", text: A.cap(who) }), A.shareButton())); | |
| if (!years.length) { | |
| out.replaceChildren(head, el("p", { class: "lede", style: "margin-top:18px", text: `No hay ${who} con esta actividad en ${place}.` })); | |
| return; | |
| } | |
| const fact = (big, parts) => el("div", { class: "fact" }, el("p", { class: "big", text: big }), rich("p", {}, parts)); | |
| const prev = made(Y - 1), prev2 = made(Y - 2); | |
| const facts = el("div", { class: "facts x4" }, | |
| fact(n(live), [`${who} activas: renovaron o abrieron su matrícula en ${Y}.`]), | |
| fact(n(prev), [`se crearon en ${Y - 1}`, prev2 ? `, ${A.signedPct(prev / prev2 - 1)} frente a ${Y - 2} (${n(prev2)}).` : "."]), | |
| fact(n(made(Y)), [`se han creado en ${Y}, hasta el ${A.dayMonth(s.data_through)}.`]), | |
| st.camara | |
| ? fact(share(liveCountry ? live / liveCountry : 0), ["de las ", { b: n(liveCountry) }, ` activas del país${inAct} están en ${place}.`]) | |
| : a.kind === "todas" | |
| ? fact(A.n1(1e5 * perCam.reduce((t, r) => t + r.activas, 0) / perCam.reduce((t, r) => t + r.poblacion, 0)), [`${who} activas por cada 100.000 habitantes del país, con la población que el DANE proyecta para ${Y}.`]) | |
| : fact(share(live / allLive), ["de todas las empresas activas del país, ", { b: n(allLive) }, ", se dedican a esto."])); | |
| // Created each year, and how many of each year still renew: the last 30, | |
| // or, for one activity or section, from 2013. Earlier cohorts counted by | |
| // activity leave out most of the companies that closed, whose activity was | |
| // never converted to CIIU rev. 4 (99.8 % carry it from 2013, 71 % in | |
| // 2005), and would show fewer created and more remaining than there were. | |
| const from = a.kind === "todas" ? Y - 29 : Math.max(Y - 29, 2013); | |
| const span = years.filter((r) => r.anio >= from && r.anio <= Y); | |
| const f1 = figure({ title: "Cuántas se crean cada año, y cuántas siguen", | |
| sub: `La columna entera es lo que se creó ese año; la parte de abajo, las que siguen activas en ${Y}. ${Y} va hasta el ${A.date(s.data_through)}.${a.kind === "todas" ? "" : " Por actividad, desde 2013: antes, la mayoría de las empresas que cerraron no tiene su actividad en la clasificación actual (CIIU rev. 4)."}`, | |
| sql: sqlYears, source: "RUES · Confecámaras. Cálculos de AMC." }); | |
| f1.body.append(A.legend([{ label: `Siguen activas en ${Y}`, color: "var(--accent)" }, { label: "Ya no renuevan", color: "var(--c1)" }])); | |
| columns(f1.body, span.map((r) => ({ label: r.anio % 5 === 0 ? String(r.anio) : "", value: r.creadas, part: r.siguen_activas, | |
| tip: { title: String(r.anio), rows: [{ label: "Creadas", value: n(r.creadas) }, { label: "Siguen activas", value: n(r.siguen_activas) }, | |
| { label: "De cada 100", value: n(100 * r.siguen_activas / r.creadas) }] } })), | |
| { label: `Empresas creadas por año en ${a.nombre}, ${place}`, labelEvery: () => 1 }); | |
| f1.fig.append(tableView(`Empresas creadas y activas por año: ${a.nombre}, ${place}`, ["Año", "Creadas", "Siguen activas", "De cada 100"], | |
| years.filter((r) => r.anio >= from).reverse().map((r) => [String(r.anio), n(r.creadas), n(r.siguen_activas), n(100 * r.siguen_activas / r.creadas)]))); | |
| // Month by month, the last 24: what opened last month, without SQL. | |
| const byMonth = new Map(months.map((r) => [r.mes.slice(0, 7), r.creadas])); | |
| const last = s.data_through.slice(0, 7), [ly, lm] = last.split("-").map(Number); | |
| const m24 = Array.from({ length: 24 }, (_, i) => { const dt = new Date(Date.UTC(ly, lm - 24 + i, 1)); return dt.toISOString().slice(0, 7); }); | |
| const mLabel = (k) => `${MES[+k.slice(5) - 1]} ${k.slice(0, 4)}`; | |
| const prevM = m24[22]; | |
| const f1m = figure({ title: `Mes a mes: ${n(byMonth.get(prevM) ?? 0)} en ${mLabel(prevM)}`, | |
| sub: `Empresas creadas cada mes, los últimos 24. ${A.cap(mLabel(last))} va hasta el ${A.dayMonth(s.data_through)}.`, | |
| sql: sqlMonths, source: "RUES · Confecámaras. Cálculos de AMC." }); | |
| columns(f1m.body, m24.map((k) => ({ label: k.endsWith("-01") || k === m24[0] ? mLabel(k) : "", value: byMonth.get(k) ?? 0, | |
| color: k === last ? "var(--other)" : null, tip: { title: mLabel(k), rows: [{ label: "Creadas", value: n(byMonth.get(k) ?? 0) }] } })), | |
| { label: `Empresas creadas por mes en ${a.nombre}, ${place}`, labelEvery: () => 1 }); | |
| f1m.fig.append(tableView(`Empresas creadas por mes: ${a.nombre}, ${place}`, ["Mes", "Creadas"], | |
| m24.slice().reverse().map((k) => [mLabel(k), n(byMonth.get(k) ?? 0)]))); | |
| // Where the live ones are: every chamber, the chosen one marked. | |
| const top = byCam.slice(0, 12); | |
| const chosen = st.camara ? byCam.find((r) => r.codigo_camara === st.camara) : null; | |
| if (chosen && !top.includes(chosen)) top.push(chosen); | |
| const f2 = figure({ title: "Dónde están las activas", sub: `Por cámara de comercio, de ${n(liveCountry)} ${who} activas del país${inAct}.`, | |
| sql: sqlCams, source: "RUES · Confecámaras. Cálculos de AMC." }); | |
| barList(f2.body, top.map((r) => ({ label: A.title(r.camara), value: r.activas, strong: r === chosen, dim: chosen && r !== chosen })), | |
| { unit: "Activas", labelWidth: 220 }); | |
| f2.fig.append(tableView(`Empresas activas por cámara: ${a.nombre}`, ["Cámara", "Activas", "Del país"], | |
| byCam.map((r) => [A.title(r.camara), n(r.activas), pct(r.activas / liveCountry)]))); | |
| // Per 100,000 inhabitants, by chamber: where the activity is dense, not just big. | |
| // Ranked only where 20 or more are live: below that one company moves the rate. | |
| const MIN = 20, ranked = perCam.filter((r) => r.activas >= MIN); | |
| const chosenCam = st.camara ? perCam.find((r) => r.codigo_camara === st.camara) : null; | |
| const f2p = figure({ title: ranked.length ? `Por cada 100.000 habitantes: ${A.title(ranked[0].camara)} primero` : "Por cada 100.000 habitantes", | |
| sub: `${A.cap(who)} activas por cada 100.000 habitantes de la jurisdicción de cada cámara de comercio: los municipios que cubre, con la población que el DANE proyecta para ${Y}. Una jurisdicción es una ciudad con sus alrededores, y una empresa cuenta donde está matriculada, no donde opera. En la gráfica, solo cámaras con ${MIN} o más activas: con menos, una sola empresa mueve la cifra; la tabla las trae todas.`, | |
| sql: sqlChamber, source: "RUES · Confecámaras y DANE (proyecciones de población). Cálculos de AMC." }); | |
| const topR = ranked.slice(0, 12); | |
| if (chosenCam && chosenCam.activas >= MIN && !topR.includes(chosenCam)) topR.push(chosenCam); | |
| barList(f2p.body, topR.map((r) => ({ label: A.title(r.camara), value: r.por_100000, strong: r === chosenCam, dim: chosenCam && r !== chosenCam })), | |
| { unit: "Por 100.000 habitantes", labelWidth: 220, format: (v) => A.n1(v) }); | |
| f2p.fig.append(tableView(`Activas por cada 100.000 habitantes, por cámara: ${a.nombre}`, ["Cámara", "Activas", "Habitantes", "Por 100.000"], | |
| perCam.map((r) => [A.title(r.camara), n(r.activas), n(r.poblacion), A.n1(r.por_100000)]))); | |
| // "En resumen": what the counts mean, in sentences computed against the | |
| // same place and type of company across every activity (the baseline): | |
| // the pace against the five years before, survival against the rest, the | |
| // median age, and where the activity weighs more than its share. | |
| const tipoCond = st.tipo !== "todas" ? `is_juridical = ${st.tipo === "juridicas"}` : "TRUE"; | |
| const base = await d.query(`SELECT codigo_camara, formation_year AS anio, sum(empresas) AS creadas, sum(activas) AS siguen_activas | |
| FROM explorer WHERE ${tipoCond} AND formation_year IS NOT NULL GROUP BY 1, 2`); | |
| if (mine !== run) return; | |
| const baseHere = base.filter((r) => !st.camara || r.codigo_camara === st.camara); | |
| const sumBy = (rows, k) => rows.reduce((t, r) => t + r[k], 0); | |
| const medianAge = (rows) => { | |
| const live = [...rows].filter((r) => r.siguen_activas > 0).sort((p, q) => q.anio - p.anio), half = sumBy(live, "siguen_activas") / 2; | |
| let acc = 0; const r = live.find((x) => (acc += x.siguen_activas) >= half); | |
| return r ? Y - r.anio : null; | |
| }; | |
| const lines = [], what = a.kind === "seccion" ? "este sector" : "esta actividad"; | |
| const five = [2, 3, 4, 5, 6].map((k) => made(Y - k)); | |
| const avg5 = five.reduce((t, v) => t + v, 0) / 5; | |
| if (avg5 > 0 && prev > 0) lines.push([`En ${Y - 1} se crearon `, { b: n(prev) }, `, ${A.signedPct(prev / avg5 - 1)} frente al promedio de los cinco años anteriores (${n(avg5)} al año).`]); | |
| const c5 = y.get(Y - 5), b5 = baseHere.filter((r) => r.anio === Y - 5); | |
| if (a.kind !== "todas" && c5?.creadas >= 20 && b5.length) { | |
| lines.push([`De las creadas en ${Y - 5}, siguen activas `, { b: share(c5.siguen_activas / c5.creadas) }, | |
| `; en todas las actividades${st.camara ? ` de ${place}` : ""}, ${share(sumBy(b5, "siguen_activas") / sumBy(b5, "creadas"))}.`]); | |
| } | |
| const age = medianAge(years), baseAge = medianAge(d3.rollups(baseHere, (g) => ({ anio: g[0].anio, siguen_activas: sumBy(g, "siguen_activas") }), (r) => r.anio).map(([, v]) => v)); | |
| if (age != null) lines.push(["La mitad de las activas tiene menos de ", { b: `${n(age)} ${age === 1 ? "año" : "años"}` }, | |
| a.kind !== "todas" && baseAge != null ? `; en todas las actividades${st.camara ? ` de ${place}` : ""}, ${n(baseAge)}.` : "."]); | |
| if (a.kind !== "todas") { | |
| const allBy = new Map(d3.rollups(base, (g) => sumBy(g, "siguen_activas"), (r) => r.codigo_camara)); | |
| const allTotal = [...allBy.values()].reduce((t, v) => t + v, 0); | |
| const lq = (r) => (r.activas / liveCountry) / ((allBy.get(r.codigo_camara) || 1) / allTotal); | |
| const pick = st.camara ? byCam.find((r) => r.codigo_camara === st.camara) : [...byCam].filter((r) => r.activas >= 30).sort((p, q) => lq(q) - lq(p))[0]; | |
| if (pick && liveCountry) { | |
| const x = lq(pick), name = camName.get(pick.codigo_camara); | |
| lines.push(x >= 1.1 ? ["Pesa ", { b: `${A.n1(x).replace(/,0$/, "")} veces más` }, ` en ${name} que en el país: ${share(pick.activas / liveCountry)} de las activas de ${what} están allí, frente al ${share((allBy.get(pick.codigo_camara) || 0) / allTotal)} de todas las empresas.`] | |
| : x <= 0.9 ? ["En ", { b: name }, ` pesa menos que en el país: ${share(pick.activas / liveCountry)} de las activas de ${what}, frente al ${share((allBy.get(pick.codigo_camara) || 0) / allTotal)} de todas las empresas.`] | |
| : ["En ", { b: name }, " pesa lo mismo que en el país."]); | |
| } | |
| } | |
| const summary = lines.length ? el("section", { class: "resumen" }, el("h3", { text: "En resumen" }), el("ul", {}, ...lines.map((l) => rich("li", {}, l)))) : null; | |
| // Its finances: the median company of the activity (or the section) among | |
| // those that report statements to Supersociedades, nationally, in the | |
| // latest year with enough of them (benchmarks, read only for this activity). | |
| const finSlot = el("div"); | |
| if ((a.kind === "clase" || a.kind === "seccion") && st.tipo !== "naturales") { | |
| d.query(`SELECT anio_fiscal, metrica, empresas, p50 FROM benchmarks WHERE nivel = ${d.lit(a.kind)} AND codigo = ${d.lit(a.clase)} | |
| AND anio_fiscal = (SELECT max(anio_fiscal) FROM benchmarks WHERE nivel = ${d.lit(a.kind)} AND codigo = ${d.lit(a.clase)})`).then((B) => { | |
| if (mine !== run || !B.length) return; | |
| const m = Object.fromEntries(B.map((r) => [r.metrica, r])); | |
| if (!m.ingresos || m.ingresos.empresas < 10) return; | |
| finSlot.replaceChildren(el("section", { class: "resumen" }, | |
| el("h3", { text: "Sus finanzas" }), | |
| rich("p", { class: "lede" }, [`La empresa mediana de ${what} entre las `, { b: n(m.ingresos.empresas) }, ` que reportan estados financieros a la Supersociedades, en todo el país, en ${m.ingresos.anio_fiscal}. Los bancos, las aseguradoras y las que cotizan en bolsa reportan a otra superintendencia.`]), | |
| el("div", { class: "facts x4" }, | |
| fact(`${A.money(m.ingresos.p50)}`, ["de pesos de ingresos al año."]), | |
| m.margen ? fact(A.pct1(m.margen.p50), ["le queda de cada peso que vende (margen neto)."]) : null, | |
| m.crecimiento ? fact(A.signedPct(m.crecimiento.p50), [`cambiaron sus ingresos frente a ${m.ingresos.anio_fiscal - 1}.`]) : null, | |
| m.endeudamiento ? fact(A.pct(m.endeudamiento.p50), ["de sus activos está financiado con deuda."]) : null))); | |
| A.reveal(finSlot); | |
| }).catch(() => {}); | |
| } | |
| const blocks = [head, facts, summary, finSlot, f1.fig, f1m.fig, f2.fig, f2p.fig].filter(Boolean); | |
| if (st.tipo !== "naturales") { | |
| const f3 = figure({ title: "Las que más venden", wide: false, | |
| sub: `Ingresos de su último año reportado (${fy - 1} o ${fy}), entre las que reportan a la Superintendencia de Sociedades.`, | |
| sql: sqlFirms, source: "Superintendencia de Sociedades (estados financieros NIIF) y RUES · Confecámaras. Cálculos de AMC." }); | |
| if (!firms.length) f3.body.append(el("p", { class: "lede", text: `Ninguna empresa de esta actividad en ${place} reportó estados financieros en ${fy - 1} o ${fy}. Solo reportan las obligadas, unas 30.000 al año: eso no dice que sean pequeñas.` })); | |
| else { | |
| f3.body.append(el("div", { class: "firms" }, ...firms.map((r, i) => el("div", { class: "firm" }, | |
| el("span", { class: "k", text: String(i + 1) }), | |
| el("a", { href: `../historias/tu-empresa/index.html#nit=${r.nit}`, text: r.display_name }), | |
| el("span", { class: "v", text: `${money(r.ingresos)} de pesos` }), | |
| el("span", { class: "s", text: `Ingresos de ${r.anio_fiscal}${r.is_live ? "" : " · no renovó en " + Y}` }))))); | |
| f3.fig.append(tableView(`Las que más venden: ${a.nombre}, ${place}`, ["Empresa", "Año", "Ingresos (millones de pesos)"], | |
| firms.map((r) => [r.display_name, String(r.anio_fiscal), n(r.ingresos / 1e3)]))); | |
| } | |
| blocks.push(f3.fig); | |
| // Which of them DIAN qualifies as large taxpayers, and how many of the | |
| // activity's live companies here it does, once the list arrives. | |
| const gcWhere = [actCond(a), st.camara && `codigo_camara = ${d.lit(st.camara)}`, "is_live"].filter(Boolean).join(" AND "); | |
| gc.then((ok) => (ok ? Promise.all([ | |
| firms.length ? d.query(`SELECT nit FROM large_taxpayers WHERE nit IN (${firms.map((r) => d.lit(r.nit)).join(", ")})`) : [], | |
| d.query(`SELECT count(*) FILTER (WHERE ${gcWhere}) AS k, max(hasta) AS hasta FROM large_taxpayers`)]) : null)).then((r) => { | |
| if (!r || mine !== run) return; | |
| const on = new Set(r[0].map((x) => x.nit)); | |
| f3.body.querySelectorAll(".firm .s").forEach((s, i) => { if (on.has(firms[i]?.nit)) s.append(" · gran contribuyente de la DIAN"); }); | |
| const [{ k, hasta }] = r[1], of = a.kind === "todas" ? "" : " de esta actividad"; | |
| const lead = k === 0 ? `Ninguna empresa activa${of} en ${place} está` : k === 1 ? `Una empresa activa${of} en ${place} está` | |
| : `${n(k)} empresas activas${of} en ${place} están`; | |
| f3.body.append(el("p", { class: "lede", style: "margin:12px 0 0;font-size:13.5px", | |
| text: `${lead} en la lista de grandes contribuyentes de la DIAN para ${hasta}: una calificación tributaria, por ingresos, patrimonio e impuestos.` })); | |
| }).catch(() => {}); | |
| // What the activity's companies here signed with the State, by year, | |
| // and the ten with the most value. Only the companies the registry | |
| // names (D33): persons and consortia are counted by entity, not by | |
| // activity. In its place when its files arrive. | |
| const slot = el("div"); | |
| blocks.push(slot); | |
| const kWhere = [actCond(a), st.camara && `codigo_camara = ${d.lit(st.camara)}`].filter(Boolean).join(" AND ") || "TRUE"; | |
| const sqlK = `SELECT anio, sum(empresas) AS empresas, sum(contratos) AS contratos,\n sum(valor_constante) AS valor_constante, any_value(pesos_de) AS pesos_de\nFROM explorer_contracts\nWHERE ${kWhere}\nGROUP BY 1 ORDER BY 1`; | |
| const sqlKF = `SELECT display_name, nit, contratos, entidades, primero, ultimo, valor_constante, pesos_de, is_live\nFROM explorer_contractors\nWHERE ${kWhere}\nORDER BY valor_constante DESC NULLS LAST, nit LIMIT 10`; | |
| secop.then((ok) => (ok ? Promise.all([d.query(sqlK), d.query(sqlKF)]) : null)).then((r) => { | |
| if (mine !== run) return; | |
| const figs = r ? contractFigures(r[0], r[1], sqlK, sqlKF, place, a.kind === "todas" ? "las empresas" : "las empresas de esta actividad") : []; | |
| if (figs.length) { slot.replaceWith(...figs); A.reveal(out); } else slot.remove(); | |
| }).catch(() => slot.remove()); | |
| } | |
| out.replaceChildren(...blocks); | |
| A.reveal(out); | |
| if (scroll) out.scrollIntoView({ behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "start" }); | |
| } | |
| // With no choice in the address, show the whole country at once: the reader | |
| // sees what an answer looks like before choosing anything. | |
| if (!st.clase) st.clase = "todas"; | |
| markQuick(); | |
| render(); | |
| // A shared link opened in a tab already on this page changes only the hash. | |
| addEventListener("hashchange", () => { | |
| const q = new URLSearchParams(location.hash.slice(1)); | |
| const c = q.get("clase"), k = q.get("camara") || "", ti = q.get("tipo") || "todas"; | |
| if (c === st.clase && k === st.camara && ti === st.tipo) return; // our own replaceState | |
| if (!byCode.has(c)) return; | |
| st.clase = c; st.camara = camName.has(k) ? k : ""; st.tipo = TIPOS.some(([x]) => x === ti) ? ti : "todas"; | |
| sel.value = st.camara; | |
| for (const x of seg.children) x.setAttribute("aria-pressed", String(x.dataset.k === st.tipo)); | |
| markQuick(); | |
| render(true); | |
| }); | |
| } catch (e) { | |
| A.fail(app, e, "el explorador"); | |
| } | |
| </script> | |
| </body> | |
| </html> | |