feat(map): refactor con 6 variables toggleables (listings/entire/revenue/licencia/hut_plazas/hut_registros)

This commit is contained in:
2026-07-15 10:19:31 +02:00
parent 940c9ebb3c
commit bb21bebc65
3 changed files with 333 additions and 151 deletions
+297 -151
View File
@@ -11,6 +11,22 @@ const filterOptions = [
{ value: "Formentera", label: "Formentera" },
];
interface VariableOption {
value: string;
labelKey: string;
descKey: string;
scope: "airbnb" | "hut";
}
const variableOptions: VariableOption[] = [
{ value: "airbnb_listings", labelKey: "map.variable_airbnb_listings", descKey: "map.variable_airbnb_listings_desc", scope: "airbnb" },
{ value: "airbnb_entire_homes", labelKey: "map.variable_airbnb_entire", descKey: "map.variable_airbnb_entire_desc", scope: "airbnb" },
{ value: "airbnb_revenue_total", labelKey: "map.variable_airbnb_revenue", descKey: "map.variable_airbnb_revenue_desc", scope: "airbnb" },
{ value: "airbnb_con_licencia", labelKey: "map.variable_airbnb_con_licencia", descKey: "map.variable_airbnb_con_licencia_desc", scope: "airbnb" },
{ value: "hut_plazas", labelKey: "map.variable_hut_plazas", descKey: "map.variable_hut_plazas_desc", scope: "hut" },
{ value: "hut_registros", labelKey: "map.variable_hut_registros", descKey: "map.variable_hut_registros_desc", scope: "hut" },
];
const popupTitleTmpl = t(lang, "map.popup_title");
---
@@ -40,7 +56,7 @@ const popupTitleTmpl = t(lang, "map.popup_title");
</div>
<div
class="absolute top-3 left-3 z-20 bg-white/95 backdrop-blur border border-stone-200 rounded-lg shadow-sm p-3 max-w-xs"
class="absolute top-3 left-3 z-20 bg-white/95 backdrop-blur border border-stone-200 rounded-lg shadow-sm p-3 w-64 max-w-[calc(100vw-1.5rem)]"
>
<div class="mb-2">
<label for="isla-filter" class="block text-xs font-medium text-stone-700 mb-1">
@@ -55,6 +71,22 @@ const popupTitleTmpl = t(lang, "map.popup_title");
))}
</select>
</div>
<div class="border-t border-stone-200 pt-2 mb-2">
<label for="variable-select" class="block text-xs font-medium text-stone-700 mb-1">
{t(lang, "map.variable_label")}
</label>
<select
id="variable-select"
class="w-full text-sm border border-stone-300 rounded px-2 py-1 bg-white"
>
{variableOptions.map((o) => (
<option value={o.value} data-scope={o.scope}>{t(lang, o.labelKey)}</option>
))}
</select>
<p id="variable-desc" class="text-[10px] text-stone-500 mt-1 leading-tight"></p>
</div>
<div class="border-t border-stone-200 pt-2">
<p class="text-xs font-medium text-stone-700 mb-1">
{t(lang, "map.layer_label")}
@@ -76,45 +108,7 @@ const popupTitleTmpl = t(lang, "map.popup_title");
<p class="font-medium text-stone-800 mb-2">
{t(lang, "map.legend_title")}
</p>
<div class="mb-2">
<p class="font-semibold text-stone-700">
{t(lang, "map.legend_airbnb_title")}
</p>
<p class="text-stone-500 mb-1">
{t(lang, "map.legend_airbnb_desc")}
</p>
<div class="flex items-center gap-0.5 h-2 rounded overflow-hidden">
<span class="flex-1" style="background:#fef3c7"></span>
<span class="flex-1" style="background:#fde68a"></span>
<span class="flex-1" style="background:#fbbf24"></span>
<span class="flex-1" style="background:#f97316"></span>
<span class="flex-1" style="background:#c2410c"></span>
<span class="flex-1" style="background:#7c2d12"></span>
</div>
<div class="flex justify-between text-[10px] text-stone-500 mt-0.5">
<span>0</span>
<span>100+</span>
</div>
</div>
<div>
<p class="font-semibold text-stone-700">
{t(lang, "map.legend_hut_title")}
</p>
<p class="text-stone-500 mb-1">
{t(lang, "map.legend_hut_desc")}
</p>
<div class="flex items-center gap-0.5 h-2 rounded overflow-hidden">
<span class="flex-1" style="background:#e0f2fe"></span>
<span class="flex-1" style="background:#7dd3fc"></span>
<span class="flex-1" style="background:#0ea5e9"></span>
<span class="flex-1" style="background:#0369a1"></span>
<span class="flex-1" style="background:#0c4a6e"></span>
</div>
<div class="flex justify-between text-[10px] text-stone-500 mt-0.5">
<span>0</span>
<span>7K+</span>
</div>
</div>
<div id="legend-content"></div>
</div>
</div>
@@ -130,35 +124,154 @@ const popupTitleTmpl = t(lang, "map.popup_title");
const lang = (shell.dataset.lang as "ca" | "es") ?? "ca";
const popupTitleTmpl = shell.dataset.popupTitleTmpl ?? "Secció {cusec}";
const t_ca: Record<string, string> = {
municipi: "Municipi",
isla: "Illa",
const T: Record<string, string> = {
municipi: lang === "es" ? "Municipio" : "Municipi",
isla: lang === "es" ? "Isla" : "Illa",
listings: "Listings Airbnb",
hut_registros: "Registres HUT",
hut_plazas: "Places HUT",
granularitat: "Granularitat",
sense_dades: "Sense dades",
seccion_censal: "secció censal",
entire_homes: lang === "es" ? "Pisos enteros" : "Pisos sencers",
revenue: lang === "es" ? "Ingresos L365d" : "Ingressos L365d",
con_licencia: lang === "es" ? "Con licencia Airbnb" : "Amb llicència Airbnb",
hut_registros: lang === "es" ? "Registros HUT" : "Registres HUT",
hut_plazas: lang === "es" ? "Plazas HUT" : "Places HUT",
granularitat: lang === "es" ? "Granularidad" : "Granularitat",
seccion_censal: lang === "es" ? "sección censal" : "secció censal",
municipio: "municipi",
illa: "illa",
list: "Llista",
empty: lang === "es" ? "Sin datos" : "Sense dades",
legend_applies_to: lang === "es" ? "Aplica a" : "Aplica a",
legend_islands_mm: lang === "es" ? "Mallorca / Menorca (sección censal)" : "Mallorca / Menorca (secció censal)",
legend_islands_ef: lang === "es" ? "Ibiza / Formentera (municipio)" : "Eivissa / Formentera (municipi)",
};
const t_es: Record<string, string> = {
municipi: "Municipio",
isla: "Isla",
listings: "Listings Airbnb",
hut_registros: "Registros HUT",
hut_plazas: "Plazas HUT",
granularitat: "Granularidad",
sense_dades: "Sin datos",
seccion_censal: "sección censal",
municipio: "municipio",
illa: "isla",
list: "Lista",
// Descriptions per variable (used in selector helper text and legend).
const DESC: Record<string, string> = {
airbnb_listings:
lang === "es"
? "Total de listings activos por sección censal"
: "Total de listings actius per secció censal",
airbnb_entire_homes:
lang === "es"
? "Listings que son pisos completos — proxy de conversión residencial → turístico"
: "Listings que són pisos complets — proxy de conversió residencial → turístic",
airbnb_revenue_total:
lang === "es"
? "Suma estimada de ingresos de los últimos 365 días por sección"
: "Suma estimada dels ingressos dels darrers 365 dies per secció",
airbnb_con_licencia:
lang === "es"
? "Listings con campo `license` rellenado (autorreportado, no validado)"
: "Listings amb camp `license` emplenat (autorreportat, no validat)",
hut_plazas:
lang === "es"
? "Plazas registradas en HUT del Consell d'Eivissa (municipio)"
: "Places registrades al HUT del Consell d'Eivissa (municipi)",
hut_registros:
lang === "es"
? "Número de registros HUT activos (municipio)"
: "Nombre de registres HUT actius (municipi)",
};
const T = lang === "es" ? t_es : t_ca;
// Variable → paint + legend. Airbnb colors are warm (orange),
// HUT colors are cool (blue). Each entry's stops double as legend ticks.
const LAYERS: Record<string, {
scope: "airbnb" | "hut";
field: string;
colorExpr: any;
stops: number[];
colors: string[];
label: string;
desc: string;
appliesTo: string;
}> = {
airbnb_listings: {
scope: "airbnb",
field: "airbnb_listings",
colorExpr: [
"step", ["coalesce", ["get", "airbnb_listings"], 0],
"#fef3c7", 1, "#fde68a", 10, "#fbbf24", 50, "#f97316", 150, "#c2410c", 400, "#7c2d12",
],
stops: [0, 1, 10, 50, 150, 400],
colors: ["#fef3c7", "#fde68a", "#fbbf24", "#f97316", "#c2410c", "#7c2d12"],
label: T.listings,
desc: DESC.airbnb_listings,
appliesTo: T.legend_islands_mm,
},
airbnb_entire_homes: {
scope: "airbnb",
field: "airbnb_entire_homes",
colorExpr: [
"step", ["coalesce", ["get", "airbnb_entire_homes"], 0],
"#fef3c7", 1, "#fde68a", 5, "#fbbf24", 30, "#f97316", 100, "#c2410c", 250, "#7c2d12",
],
stops: [0, 1, 5, 30, 100, 250],
colors: ["#fef3c7", "#fde68a", "#fbbf24", "#f97316", "#c2410c", "#7c2d12"],
label: T.entire_homes,
desc: DESC.airbnb_entire_homes,
appliesTo: T.legend_islands_mm,
},
airbnb_revenue_total: {
scope: "airbnb",
field: "airbnb_revenue_total",
colorExpr: [
"step", ["coalesce", ["get", "airbnb_revenue_total"], 0],
"#fef3c7", 100000, "#fde68a", 500000, "#fbbf24",
2000000, "#f97316", 5000000, "#c2410c", 10000000, "#7c2d12",
],
stops: [0, 100000, 500000, 2000000, 5000000, 10000000],
colors: ["#fef3c7", "#fde68a", "#fbbf24", "#f97316", "#c2410c", "#7c2d12"],
label: T.revenue,
desc: DESC.airbnb_revenue_total,
appliesTo: T.legend_islands_mm,
},
airbnb_con_licencia: {
scope: "airbnb",
field: "airbnb_con_licencia",
colorExpr: [
"step", ["coalesce", ["get", "airbnb_con_licencia"], 0],
"#fef3c7", 1, "#fde68a", 10, "#fbbf24", 50, "#f97316", 150, "#c2410c", 400, "#7c2d12",
],
stops: [0, 1, 10, 50, 150, 400],
colors: ["#fef3c7", "#fde68a", "#fbbf24", "#f97316", "#c2410c", "#7c2d12"],
label: T.con_licencia,
desc: DESC.airbnb_con_licencia,
appliesTo: T.legend_islands_mm,
},
hut_plazas: {
scope: "hut",
field: "hut_plazas",
colorExpr: [
"step", ["coalesce", ["get", "hut_plazas"], 0],
"#e0f2fe", 1, "#7dd3fc", 1000, "#0ea5e9", 3000, "#0369a1", 7000, "#0c4a6e",
],
stops: [0, 1, 1000, 3000, 7000],
colors: ["#e0f2fe", "#7dd3fc", "#0ea5e9", "#0369a1", "#0c4a6e", "#0c4a6e"],
label: T.hut_plazas,
desc: DESC.hut_plazas,
appliesTo: T.legend_islands_ef,
},
hut_registros: {
scope: "hut",
field: "hut_registros",
colorExpr: [
"step", ["coalesce", ["get", "hut_registros"], 0],
"#e0f2fe", 1, "#7dd3fc", 200, "#0ea5e9", 1000, "#0369a1", 2000, "#0c4a6e",
],
stops: [0, 1, 200, 1000, 2000],
colors: ["#e0f2fe", "#7dd3fc", "#0ea5e9", "#0369a1", "#0c4a6e", "#0c4a6e"],
label: T.hut_registros,
desc: DESC.hut_registros,
appliesTo: T.legend_islands_ef,
},
};
const fmt = (n: number) =>
new Intl.NumberFormat(lang === "ca" ? "ca-ES" : "es-ES").format(n);
const fmtShort = (n: number) => {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return fmt(n);
};
const fmtMoney = (n: number) => `$${fmtShort(n)}`;
const map = new maplibregl.Map({
container: "map",
@@ -179,11 +292,7 @@ const popupTitleTmpl = t(lang, "map.popup_title");
},
},
layers: [
{
id: "background",
type: "background",
paint: { "background-color": "#f5f5f4" },
},
{ id: "background", type: "background", paint: { "background-color": "#f5f5f4" } },
{
id: "secciones-line",
type: "line",
@@ -191,10 +300,7 @@ const popupTitleTmpl = t(lang, "map.popup_title");
"source-layer": "cases",
paint: {
"line-color": "#a8a29e",
"line-width": [
"interpolate", ["linear"], ["zoom"],
6, 0.1, 10, 0.3, 13, 0.6,
],
"line-width": ["interpolate", ["linear"], ["zoom"], 6, 0.1, 10, 0.3, 13, 0.6],
},
},
{
@@ -204,12 +310,7 @@ const popupTitleTmpl = t(lang, "map.popup_title");
"source-layer": "cases",
filter: ["match", ["get", "isla"], ["Mallorca", "Menorca"], true, false],
paint: {
"fill-color": [
"step",
["coalesce", ["get", "airbnb_listings"], 0],
"#fef3c7", 1, "#fde68a", 10, "#fbbf24",
50, "#f97316", 150, "#c2410c", 400, "#7c2d12",
],
"fill-color": LAYERS.airbnb_listings.colorExpr,
"fill-opacity": 0.7,
},
},
@@ -220,12 +321,7 @@ const popupTitleTmpl = t(lang, "map.popup_title");
"source-layer": "cases",
filter: ["match", ["get", "isla"], ["Eivissa", "Formentera"], true, false],
paint: {
"fill-color": [
"step",
["coalesce", ["get", "hut_plazas"], 0],
"#e0f2fe", 1, "#7dd3fc", 1000, "#0ea5e9",
3000, "#0369a1", 7000, "#0c4a6e",
],
"fill-color": LAYERS.hut_plazas.colorExpr,
"fill-opacity": 0.7,
},
},
@@ -237,8 +333,7 @@ const popupTitleTmpl = t(lang, "map.popup_title");
paint: {
"circle-color": [
"step", ["get", "point_count"],
"#fde68a", 10, "#fbbf24", 50, "#f97316",
200, "#c2410c", 1000, "#7c2d12",
"#fde68a", 10, "#fbbf24", 50, "#f97316", 200, "#c2410c", 1000, "#7c2d12",
],
"circle-radius": [
"step", ["get", "point_count"],
@@ -285,10 +380,6 @@ const popupTitleTmpl = t(lang, "map.popup_title");
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
map.addControl(new maplibregl.ScaleControl({ unit: "metric" }), "bottom-left");
// En Astro 7 los <script> se cargan como modulo defer, asi que el
// evento "load" del map puede dispararse ANTES de que registremos
// el handler. Patron seguro: chequear isStyleLoaded() y si ya paso,
// ejecutar el callback inmediatamente.
let readyDone = false;
const onReady = (cb: () => void) => {
if (readyDone) return;
@@ -300,7 +391,6 @@ const popupTitleTmpl = t(lang, "map.popup_title");
} else {
map.once("load", () => onReady(hideLoading));
}
// Safety: si el source pmtiles rompe, isStyleLoaded nunca sera true.
setTimeout(() => onReady(hideLoading), 3000);
function hideLoading() {
@@ -308,72 +398,119 @@ const popupTitleTmpl = t(lang, "map.popup_title");
if (loading) loading.style.display = "none";
}
// Active variable starts as the default paint already set in style.
let activeVariable = "airbnb_listings";
function applyVariable(variable: string) {
const cfg = LAYERS[variable];
if (!cfg) return;
activeVariable = variable;
if (cfg.scope === "airbnb") {
map.setPaintProperty("secciones-airbnb-fill", "fill-color", cfg.colorExpr);
map.setPaintProperty("secciones-hut-fill", "fill-color", ["literal", "rgba(0,0,0,0)"]);
map.setLayoutProperty("secciones-airbnb-fill", "visibility", "visible");
map.setLayoutProperty("secciones-hut-fill", "visibility", "none");
} else {
map.setPaintProperty("secciones-hut-fill", "fill-color", cfg.colorExpr);
map.setPaintProperty("secciones-airbnb-fill", "fill-color", ["literal", "rgba(0,0,0,0)"]);
map.setLayoutProperty("secciones-hut-fill", "visibility", "visible");
map.setLayoutProperty("secciones-airbnb-fill", "visibility", "none");
}
updateLegend(cfg);
const desc = document.getElementById("variable-desc");
if (desc) desc.textContent = cfg.desc;
}
function formatTick(n: number): string {
if (n === 0) return "0";
if (activeVariable === "airbnb_revenue_total") return fmtMoney(n);
return fmtShort(n);
}
function updateLegend(cfg: typeof LAYERS[string]) {
const el = document.getElementById("legend-content");
if (!el) return;
const swatches = cfg.colors
.map((c) => `<span class="flex-1" style="background:${c}"></span>`)
.join("");
const labels = cfg.stops
.map((s, i) =>
i === 0 || i === cfg.stops.length - 1
? `<span>${formatTick(s)}${i === cfg.stops.length - 1 ? "+" : ""}</span>`
: `<span></span>`,
)
.join("");
el.innerHTML = `
<p class="font-semibold text-stone-700 mb-1">${escape(cfg.label)}</p>
<p class="text-stone-500 mb-1 leading-tight">${escape(cfg.desc)}</p>
<div class="flex items-center gap-0.5 h-2 rounded overflow-hidden mb-0.5">${swatches}</div>
<div class="flex justify-between text-[10px] text-stone-500 mb-2">${labels}</div>
<p class="text-[10px] text-stone-500 italic">${T.legend_applies_to}: ${escape(cfg.appliesTo)}</p>
`;
}
map.on("load", () => {
const applyFilter = (isla: string) => {
const filterExpr =
isla === "all"
? null
: (["==", ["get", "isla"], isla] as unknown as maplibregl.FilterSpecification);
["secciones-airbnb-fill", "secciones-hut-fill", "secciones-line"].forEach(
(id) => {
const filterExpr =
isla === "all"
? null
: (["==", ["get", "isla"], isla] as unknown as maplibregl.FilterSpecification);
["secciones-airbnb-fill", "secciones-hut-fill", "secciones-line"].forEach((id) => {
if (map.getLayer(id)) {
map.setFilter(id, filterExpr);
}
},
);
if (isla === "all") {
map.setFilter("airbnb-clusters", null);
map.setFilter("airbnb-cluster-count", null);
map.setFilter("airbnb-unclustered", null);
} else if (isla === "Mallorca" || isla === "Menorca") {
const f: maplibregl.FilterSpecification = ["==", ["get", "isla"], isla];
map.setFilter("airbnb-clusters", ["all", ["has", "point_count"], f]);
map.setFilter("airbnb-cluster-count", ["all", ["has", "point_count"], f]);
map.setFilter("airbnb-unclustered", ["all", ["!", ["has", "point_count"]], f]);
} else {
map.setFilter("airbnb-clusters", ["==", ["literal", "__none__"], "__none__"]);
map.setFilter("airbnb-cluster-count", ["==", ["literal", "__none__"], "__none__"]);
map.setFilter("airbnb-unclustered", ["==", ["literal", "__none__"], "__none__"]);
}
};
});
if (isla === "all") {
map.setFilter("airbnb-clusters", null);
map.setFilter("airbnb-cluster-count", null);
map.setFilter("airbnb-unclustered", null);
} else if (isla === "Mallorca" || isla === "Menorca") {
const f: maplibregl.FilterSpecification = ["==", ["get", "isla"], isla];
map.setFilter("airbnb-clusters", ["all", ["has", "point_count"], f]);
map.setFilter("airbnb-cluster-count", ["all", ["has", "point_count"], f]);
map.setFilter("airbnb-unclustered", ["all", ["!", ["has", "point_count"]], f]);
} else {
const none: maplibregl.FilterSpecification = ["==", ["literal", "__none__"], "__none__"];
map.setFilter("airbnb-clusters", none);
map.setFilter("airbnb-cluster-count", none);
map.setFilter("airbnb-unclustered", none);
}
};
const select = document.getElementById("isla-filter") as HTMLSelectElement;
select?.addEventListener("change", () => applyFilter(select.value));
if (select) applyFilter(select.value);
const variableSelect = document.getElementById("variable-select") as HTMLSelectElement;
variableSelect?.addEventListener("change", () => applyVariable(variableSelect.value));
if (variableSelect) applyVariable(variableSelect.value);
const toggleChoro = document.getElementById("toggle-choropleth") as HTMLInputElement;
const toggleClu = document.getElementById("toggle-clusters") as HTMLInputElement;
toggleChoro?.addEventListener("change", () => {
const v = toggleChoro.checked ? "visible" : "none";
["secciones-airbnb-fill", "secciones-hut-fill", "secciones-line"].forEach(
(id) => {
if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", v);
},
);
const activeFillId = LAYERS[activeVariable].scope === "airbnb"
? "secciones-airbnb-fill"
: "secciones-hut-fill";
[activeFillId, "secciones-line"].forEach((id) => {
if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", v);
});
});
toggleClu?.addEventListener("change", () => {
const v = toggleClu.checked ? "visible" : "none";
["airbnb-clusters", "airbnb-cluster-count", "airbnb-unclustered"].forEach(
(id) => {
if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", v);
},
);
["airbnb-clusters", "airbnb-cluster-count", "airbnb-unclustered"].forEach((id) => {
if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", v);
});
});
map.on("click", "secciones-airbnb-fill", (e) => openPopup(e, "airbnb"));
map.on("click", "secciones-hut-fill", (e) => openPopup(e, "hut"));
map.on("mouseenter", "secciones-airbnb-fill", () => {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", "secciones-airbnb-fill", () => {
map.getCanvas().style.cursor = "";
});
map.on("mouseenter", "secciones-hut-fill", () => {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", "secciones-hut-fill", () => {
map.getCanvas().style.cursor = "";
});
map.on("mouseenter", "secciones-airbnb-fill", () => { map.getCanvas().style.cursor = "pointer"; });
map.on("mouseleave", "secciones-airbnb-fill", () => { map.getCanvas().style.cursor = ""; });
map.on("mouseenter", "secciones-hut-fill", () => { map.getCanvas().style.cursor = "pointer"; });
map.on("mouseleave", "secciones-hut-fill", () => { map.getCanvas().style.cursor = ""; });
map.on("click", "airbnb-clusters", async (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ["airbnb-clusters"] });
const clusterId = features[0]?.properties?.cluster_id;
@@ -383,12 +520,8 @@ const popupTitleTmpl = t(lang, "map.popup_title");
const coords = (features[0].geometry as { coordinates: [number, number] }).coordinates;
map.easeTo({ center: coords, zoom });
});
map.on("mouseenter", "airbnb-clusters", () => {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", "airbnb-clusters", () => {
map.getCanvas().style.cursor = "";
});
map.on("mouseenter", "airbnb-clusters", () => { map.getCanvas().style.cursor = "pointer"; });
map.on("mouseleave", "airbnb-clusters", () => { map.getCanvas().style.cursor = ""; });
});
function openPopup(e: maplibregl.MapLayerMouseEvent, kind: "airbnb" | "hut") {
@@ -398,21 +531,34 @@ const popupTitleTmpl = t(lang, "map.popup_title");
const cusec = String(p.CUSEC ?? "—");
const title = popupTitleTmpl.replace("{cusec}", cusec);
const gran = String(p.granularidad ?? "—");
const granLabel = T[gran] ?? gran;
const granLabel =
gran === "seccion_censal" ? T.seccion_censal : gran === "municipio" ? T.municipio : gran;
const listings = Number(p.airbnb_listings ?? 0);
const entire = Number(p.airbnb_entire_homes ?? 0);
const conLic = Number(p.airbnb_con_licencia ?? 0);
const plazas = Number(p.airbnb_accommodates_total ?? 0);
const revenue = Number(p.airbnb_revenue_total ?? 0);
const regs = Number(p.hut_registros ?? 0);
const hPlazas = Number(p.hut_plazas ?? 0);
const lines: string[] = [];
lines.push(`<div class="font-bold text-sm mb-1">${escape(title)}</div>`);
lines.push(`<div class="text-xs text-stone-700"><span class="text-stone-500">${T.municipi}:</span> ${escape(String(p.NMUN ?? "—"))}</div>`);
lines.push(`<div class="text-xs text-stone-700"><span class="text-stone-500">${T.isla}:</span> ${escape(String(p.isla ?? "—"))}</div>`);
const sectionTitle = (label: string) =>
`<div class="text-[10px] uppercase tracking-wide text-stone-500 mt-2 mb-0.5 border-t border-stone-200 pt-1">${label}</div>`;
if (kind === "airbnb") {
const listings = Number(p.airbnb_listings ?? 0);
const plazas = Number(p.airbnb_accommodates_total ?? 0);
lines.push(`<div class="text-xs text-stone-700 mt-1"><span class="text-stone-500">${T.listings}:</span> <b>${fmt(listings)}</b></div>`);
lines.push(`<div class="text-xs text-stone-700"><span class="text-stone-500">${T.hut_plazas}:</span> <b>${fmt(plazas)}</b></div>`);
lines.push(sectionTitle("Airbnb"));
lines.push(`<div class="text-xs text-stone-700"><span class="text-stone-500">${T.listings}:</span> <b>${fmt(listings)}</b></div>`);
if (entire) lines.push(`<div class="text-xs text-stone-700"><span class="text-stone-500">${T.entire_homes}:</span> <b>${fmt(entire)}</b></div>`);
if (conLic) lines.push(`<div class="text-xs text-stone-700"><span class="text-stone-500">${T.con_licencia}:</span> <b>${fmt(conLic)}</b></div>`);
if (plazas) lines.push(`<div class="text-xs text-stone-700"><span class="text-stone-500">${T.listings} (plazas):</span> <b>${fmt(plazas)}</b></div>`);
if (revenue) lines.push(`<div class="text-xs text-stone-700"><span class="text-stone-500">${T.revenue}:</span> <b>${fmtMoney(revenue)}</b></div>`);
} else {
const regs = Number(p.hut_registros ?? 0);
const plazas = Number(p.hut_plazas ?? 0);
lines.push(`<div class="text-xs text-stone-700 mt-1"><span class="text-stone-500">${T.hut_registros}:</span> <b>${fmt(regs)}</b></div>`);
lines.push(`<div class="text-xs text-stone-700"><span class="text-stone-500">${T.hut_plazas}:</span> <b>${fmt(plazas)}</b></div>`);
lines.push(sectionTitle("HUT"));
lines.push(`<div class="text-xs text-stone-700"><span class="text-stone-500">${T.hut_registros}:</span> <b>${fmt(regs)}</b></div>`);
lines.push(`<div class="text-xs text-stone-700"><span class="text-stone-500">${T.hut_plazas}:</span> <b>${fmt(hPlazas)}</b></div>`);
}
lines.push(`<div class="text-[10px] text-stone-500 mt-1 italic">${T.granularitat}: ${granLabel}</div>`);
new Popup({ closeButton: true })
@@ -428,4 +574,4 @@ const popupTitleTmpl = t(lang, "map.popup_title");
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
</script>
</script>
+18
View File
@@ -67,7 +67,25 @@
"layer_label": "Capa",
"layer_choropleth": "Plànol coroplètic",
"layer_clusters": "Punts Airbnb (clústers)",
"variable_label": "Variable",
"variable_airbnb_listings": "Listings Airbnb",
"variable_airbnb_listings_desc": "Total de listings actius per secció censal",
"variable_airbnb_entire": "Pisos sencers (entire home/apt)",
"variable_airbnb_entire_desc": "Listings que són pisos complets — proxy de conversió residencial → turístic",
"variable_airbnb_revenue": "Ingressos Airbnb L365d",
"variable_airbnb_revenue_desc": "Suma estimada dels ingressos dels darrers 365 dies per secció",
"variable_airbnb_con_licencia": "Listings amb llicència Airbnb",
"variable_airbnb_con_licencia_desc": "Listings amb camp `license` emplenat (autorreportat, no validat pel Govern)",
"variable_hut_plazas": "Places HUT legals",
"variable_hut_plazas_desc": "Places registrades al registre HUT del Consell d'Eivissa (municipi)",
"variable_hut_registros": "Registres HUT",
"variable_hut_registros_desc": "Nombre de registres HUT actius (municipi)",
"legend_title": "Llegenda",
"legend_scale_label": "Escala",
"legend_applies_to": "Aplica a",
"legend_islands_mallorca_menorca": "Mallorca / Menorca (secció censal)",
"legend_islands_eivissa_formentera": "Eivissa / Formentera (municipi)",
"legend_empty": "Sense dades per a aquesta variable en aquesta illa",
"legend_airbnb_title": "Mallorca / Menorca",
"legend_airbnb_desc": "Listings actius a Airbnb per secció censal",
"legend_hut_title": "Eivissa / Formentera",
+18
View File
@@ -67,7 +67,25 @@
"layer_label": "Capa",
"layer_choropleth": "Plano coroplético",
"layer_clusters": "Puntos Airbnb (clústeres)",
"variable_label": "Variable",
"variable_airbnb_listings": "Listings Airbnb",
"variable_airbnb_listings_desc": "Total de listings activos por sección censal",
"variable_airbnb_entire": "Pisos enteros (entire home/apt)",
"variable_airbnb_entire_desc": "Listings que son pisos completos — proxy de conversión residencial → turístico",
"variable_airbnb_revenue": "Ingresos Airbnb L365d",
"variable_airbnb_revenue_desc": "Suma estimada de ingresos de los últimos 365 días por sección",
"variable_airbnb_con_licencia": "Listings con licencia Airbnb",
"variable_airbnb_con_licencia_desc": "Listings con campo `license` rellenado (autorreportado, no validado por Govern)",
"variable_hut_plazas": "Plazas HUT legales",
"variable_hut_plazas_desc": "Plazas registradas en el registro HUT del Consell d'Eivissa (municipio)",
"variable_hut_registros": "Registros HUT",
"variable_hut_registros_desc": "Número de registros HUT activos (municipio)",
"legend_title": "Leyenda",
"legend_scale_label": "Escala",
"legend_applies_to": "Aplica a",
"legend_islands_mallorca_menorca": "Mallorca / Menorca (sección censal)",
"legend_islands_eivissa_formentera": "Ibiza / Formentera (municipio)",
"legend_empty": "Sin datos para esta variable en esta isla",
"legend_airbnb_title": "Mallorca / Menorca",
"legend_airbnb_desc": "Listings activos en Airbnb por sección censal",
"legend_hut_title": "Ibiza / Formentera",