import { htmlTabla, armarReferencias, lineasCerca, marcasNorma, pieNorma } from "../_js/portal.js"
function dia(f) {
if (f instanceof Date) return new Date(f.getUTCFullYear(), f.getUTCMonth(), f.getUTCDate());
const p = String(f).slice(0, 10).split("-").map(Number);
return new Date(p[0], p[1] - 1, p[2]);
}
function sinTilde(s) {
return String(s || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
}
function decDe(vals) {
const m = Math.max(0, ...vals.map((v) => Math.abs(v)));
if (!Number.isFinite(m) || m >= 10) return 1;
if (m >= 0.1) return 2;
return 3;
}
function areaDe(nombre) {
const n = sinTilde(nombre);
if (n.includes("poza")) return "LIT-Poza";
if (n.includes("pelagial")) return "PEL";
if (n.includes("norte")) return "LIT-Norte";
if (n.includes("pucon")) return "LIT-Pucón";
if (n.includes("villarrica") && n.includes("litoral")) return "LIT-Villarrica";
if (n.includes("sur")) return "LIT-Sur";
return null;
}
ALIAS = ({
"transparencia": "transparencia (secchi)",
"saturacion od": "saturacion de oxigeno"
})
function puntoDe(d) {
const bruto = +d.valor;
if (!Number.isFinite(bruto)) return null;
const censurado = String(d.calidad) === String.fromCharCode(60);
const z = d.profundidad_m === "" || d.profundidad_m == null ? null : +d.profundidad_m;
return {
t: dia(d.fecha),
fecha: String(d.fecha).slice(0, 10),
v: censurado ? bruto / 2 : bruto,
detectado: censurado ? null : bruto,
censurado,
unidad: d.unidad || "",
z: Number.isFinite(z) ? z : null,
estacion: d.estacion
};
}
cal = FileAttachment("series/dga_calidad.csv").csv({typed: false})
catq = FileAttachment("series/dga_calidad_catalogo.csv").csv({typed: false})
limites = FileAttachment("antecedentes/normativa_limites.csv").csv({typed: false})
estaciones = [...new Set(cal.map(d => d.estacion))].sort()DGA calidad
Muestreos del Banco Nacional de Aguas. Cada punto es una profundidad de una visita, no un día de una serie continua.
El círculo es una medición. El triángulo vacío significa que el laboratorio informó «bajo el límite de detección»: el punto se dibuja a la mitad de ese límite para que la visita siga visible, y no entra en el mínimo, la mediana ni el máximo.
Si la estación está en un área del lago Villarrica, el gráfico marca los umbrales del decreto 19 de 2013 para esa área. En fósforo total, nitrógeno total, clorofila-a y transparencia se agregan los rangos de estado trófico de Smith et al. (1999). La tabla bajo cada figura lista cada umbral.
parametros = [...new Set(cal.filter(d => d.estacion === estacion).map(d => d.parametro))].sort()
viewof parametro = Inputs.select(parametros, {label: "Parámetro"})puntos = cal
.filter(d => d.estacion === estacion && d.parametro === parametro)
.map(puntoDe)
.filter(Boolean)
unidad = (puntos.find(d => d.unidad) || {}).unidad || ""
detectados = puntos.map(d => d.detectado).filter(v => v != null)
{
const vals = detectados.slice().sort((a, b) => a - b)
const n = vals.length
const i = n ? (n - 1) / 2 : 0
const mediana = n ? (vals[Math.floor(i)] + vals[Math.ceil(i)]) / 2 : null
const dec = decDe(vals)
return htmlTabla(
[
{key: "n", label: "n detectados", num: true, dec: 0},
{key: "min", label: "Mínimo", num: true, dec},
{key: "mediana", label: "Mediana", num: true, dec},
{key: "max", label: "Máximo", num: true, dec},
{key: "ld", label: "Bajo detección", num: true, dec: 0}
],
[{n, min: n ? vals[0] : null, mediana, max: n ? vals[n - 1] : null, ld: puntos.filter(d => d.censurado).length}],
"calidad_estadisticos.csv"
)
}{
const maxZ = new Map()
for (const d of puntos) {
if (d.z == null) continue
const prev = maxZ.get(d.fecha)
if (prev == null || d.z > prev) maxZ.set(d.fecha, d.z)
}
const dibujo = puntos.map((d) => ({
...d,
clase: d.z == null ? "sin profundidad" : d.z <= 2 ? "superficie" : d.z === maxZ.get(d.fecha) ? "fondo" : "intermedia"
}))
const clases = ["superficie", "intermedia", "fondo", "sin profundidad"].filter((c) => dibujo.some((d) => d.clase === c))
if (!dibujo.length) return html`<p class="portal-origen">No hay valores numéricos para esta combinación.</p>`
const refs = armarReferencias(limites, parametro, unidad, estacion)
const ys = dibujo.map((d) => d.v).filter((v) => Number.isFinite(v))
const cerca = lineasCerca(refs, Math.min(...ys), Math.max(...ys))
const orden = detectados.slice().sort((a, b) => a - b)
const iMed = orden.length ? (orden.length - 1) / 2 : 0
const mediana = orden.length ? (orden[Math.floor(iMed)] + orden[Math.ceil(iMed)]) / 2 : null
const fig = Plot.plot({
width: 860,
height: 420,
marginLeft: 68,
marginBottom: 48,
color: {
legend: true,
domain: clases,
range: clases.map((c) => ({ superficie: "#1a5276", intermedia: "#5dade2", fondo: "#1B7F4E", "sin profundidad": "#7f8c8d" })[c])
},
y: {grid: true, label: unidad ? `${parametro} (${unidad})` : parametro},
x: {label: "Fecha"},
marks: [
Plot.dot(dibujo.filter((d) => !d.censurado), {x: "t", y: "v", fill: "clase", r: 3.5}),
Plot.dot(dibujo.filter((d) => d.censurado), {x: "t", y: "v", stroke: "clase", fill: "none", symbol: "triangle", r: 4}),
...marcasNorma(Plot, cerca)
]
})
const caja = document.createElement("div")
caja.append(fig)
const pie = pieNorma({ refs, cerca, mediana, unidad })
if (pie) caja.append(pie)
return caja
}{
const estacional = puntos.filter((d) => d.detectado != null).map((d) => {
const m = d.t.getMonth()
const est = m === 11 || m <= 1 ? "DEF" : m <= 4 ? "MAM" : m <= 7 ? "JJA" : "SON"
return { est, v: d.detectado }
})
const orden = ["DEF", "MAM", "JJA", "SON"]
const conteo = Object.fromEntries(orden.map((e) => [e, estacional.filter((d) => d.est === e).length]))
if (estacional.length < 8) return html``
const ys = estacional.map((d) => d.v).filter((v) => Number.isFinite(v))
const refs = armarReferencias(limites, parametro, unidad, estacion)
const cerca = lineasCerca(refs, Math.min(...ys), Math.max(...ys))
const ord = detectados.slice().sort((a, b) => a - b)
const iMed = ord.length ? (ord.length - 1) / 2 : 0
const mediana = ord.length ? (ord[Math.floor(iMed)] + ord[Math.ceil(iMed)]) / 2 : null
const fig = Plot.plot({
width: 640,
height: 320,
marginLeft: 68,
marginBottom: 44,
y: {grid: true, label: unidad ? `${parametro} (${unidad})` : parametro},
x: {label: "Estación del año", domain: orden},
marks: [
...marcasNorma(Plot, cerca),
Plot.boxY(estacional, {x: "est", y: "v", fill: "#1a5276"})
]
})
const caja = document.createElement("div")
const h = document.createElement("h2")
h.textContent = "Valores detectados por estación del año"
const p = document.createElement("p")
p.className = "portal-origen"
p.textContent = orden.map((e) => `${e} ${conteo[e]}`).join(" · ") + ". Una estación en cero es un trimestre sin visitas, no un valor nulo."
caja.append(h, fig, p)
const pie = pieNorma({ refs, cerca, mediana, unidad })
if (pie) caja.append(pie)
return caja
}viewof otras = {
const lista = estaciones.filter((e) => e !== estacion && cal.some((d) => d.estacion === e && d.parametro === parametro && d.valor !== ""))
const caja = document.createElement("div")
const rotulo = document.createElement("div")
rotulo.className = "portal-otras-rotulo"
rotulo.textContent = "Mismo parámetro en otras estaciones"
caja.append(rotulo)
if (!lista.length) {
const vacio = document.createElement("p")
vacio.className = "portal-origen"
vacio.textContent = "Ninguna otra estación mide este parámetro."
caja.append(vacio)
caja.value = []
return caja
}
const sel = Inputs.checkbox(lista)
const sc = document.createElement("div")
sc.className = "portal-otras"
sc.append(sel)
caja.append(sc)
const publicar = () => {
caja.value = sel.value
caja.dispatchEvent(new Event("input", { bubbles: true }))
}
sel.addEventListener("input", publicar)
caja.value = sel.value || []
return caja
}{
const elegidas = [estacion, ...(otras || [])]
const pts = cal
.filter((d) => elegidas.includes(d.estacion) && d.parametro === parametro)
.map(puntoDe)
.filter(Boolean)
if (!(otras || []).length || !pts.length) return html``
const ys = pts.map((d) => d.v).filter((v) => Number.isFinite(v))
const refs = armarReferencias(limites, parametro, unidad, estacion)
const cerca = lineasCerca(refs, Math.min(...ys), Math.max(...ys))
const ord = detectados.slice().sort((a, b) => a - b)
const iMed = ord.length ? (ord.length - 1) / 2 : 0
const mediana = ord.length ? (ord[Math.floor(iMed)] + ord[Math.ceil(iMed)]) / 2 : null
const fig = Plot.plot({
width: 860,
height: 400,
marginLeft: 68,
marginBottom: 44,
color: { legend: true },
x: { label: "Fecha" },
y: { grid: true, label: unidad ? `${parametro} (${unidad})` : parametro },
marks: [
Plot.dot(pts.filter((d) => !d.censurado), { x: "t", y: "v", stroke: "estacion", fill: "estacion", r: 3 }),
Plot.dot(pts.filter((d) => d.censurado), { x: "t", y: "v", stroke: "estacion", fill: "none", symbol: "triangle", r: 4 }),
...marcasNorma(Plot, cerca)
]
})
const caja = document.createElement("div")
caja.append(fig)
const pie = pieNorma({ refs, cerca, mediana, unidad })
if (pie) caja.append(pie)
return caja
}ficha = catq.filter(d => d.estacion === estacion)
etiquetas = ({
codigo: "Código",
estacion: "Estación",
fecha_min: "Desde",
fecha_max: "Hasta",
n_maestro: "Registros del maestro",
tipo: "Tipo",
destino: "Uso"
})
htmlTabla(
(ficha.columns || Object.keys(ficha[0] || {})).map((c) => ({ key: c, label: etiquetas[c] || c, num: c === "n_maestro" })),
ficha,
"calidad_" + estacion + ".csv"
)