feat(web): scaffold Astro 7 con Tailwind 4, MapLibre 5 y PMTiles 4
Stack (Node 22.13.1 + pnpm 11.9.0):
- Astro 7.0.9 (output: static, prerender=false por pagina para SSR del i18n)
- Tailwind CSS 4.3.2 via @tailwindcss/vite
- maplibre-gl 5.24.0
- pmtiles 4.4.1
- @types/node, typescript 5.9.3 (no 7.x, incompatible con @astrojs/check)
- @astrojs/check 0.9.9
Estructura:
- web/src/{components,layouts,pages,i18n,data,styles}/
- web/public/tiles/dataset.pmtiles (copia de data/output/)
- web/public/data/dataset_web.json (copia de data/output/)
- web/public/data/airbnb_listings.geojson (generado en 05_)
TypeScript strict. astro check: 0 errors, 0 warnings, 0 hints.
assets servidos:
- /tiles/dataset.pmtiles -> 200
- /data/dataset_web.json -> 200
- /data/airbnb_listings.geojson -> 200
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# build output
|
||||
dist/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
@@ -0,0 +1,22 @@
|
||||
## Development
|
||||
|
||||
When starting the dev server, use background mode:
|
||||
|
||||
```
|
||||
astro dev --background
|
||||
```
|
||||
|
||||
Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation: https://docs.astro.build
|
||||
|
||||
Consult these guides before working on related tasks:
|
||||
|
||||
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
|
||||
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
|
||||
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
|
||||
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
|
||||
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
|
||||
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -0,0 +1,46 @@
|
||||
# Astro Starter Kit: Basics
|
||||
|
||||
```sh
|
||||
pnpm create astro@latest -- --template basics
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
│ └── favicon.svg
|
||||
├── src
|
||||
│ ├── assets
|
||||
│ │ └── astro.svg
|
||||
│ ├── components
|
||||
│ │ └── Welcome.astro
|
||||
│ ├── layouts
|
||||
│ │ └── Layout.astro
|
||||
│ └── pages
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
|
||||
To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/).
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `pnpm install` | Installs dependencies |
|
||||
| `pnpm dev` | Starts local dev server at `localhost:4321` |
|
||||
| `pnpm build` | Build your production site to `./dist/` |
|
||||
| `pnpm preview` | Preview your build locally, before deploying |
|
||||
| `pnpm astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `pnpm astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
@@ -0,0 +1,10 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "web",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^7.0.9",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"pmtiles": "^4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^26.1.1",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
Generated
+3798
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
minimumReleaseAgeExclude:
|
||||
- '@astrojs/markdown-satteri@0.3.4'
|
||||
- astro@7.0.9
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"resumen_por_isla": [
|
||||
{
|
||||
"isla": "Mallorca",
|
||||
"secciones": 545,
|
||||
"airbnb_listings": 14644,
|
||||
"airbnb_entire_homes": 13925,
|
||||
"airbnb_revenue_total": 214812977,
|
||||
"airbnb_hosts_unicos": 8458,
|
||||
"airbnb_con_licencia": 14583,
|
||||
"hut_registros": 0,
|
||||
"hut_plazas": 0
|
||||
},
|
||||
{
|
||||
"isla": "Menorca",
|
||||
"secciones": 47,
|
||||
"airbnb_listings": 3531,
|
||||
"airbnb_entire_homes": 3407,
|
||||
"airbnb_revenue_total": 43203143,
|
||||
"airbnb_hosts_unicos": 1769,
|
||||
"airbnb_con_licencia": 3379,
|
||||
"hut_registros": 0,
|
||||
"hut_plazas": 0
|
||||
},
|
||||
{
|
||||
"isla": "Eivissa",
|
||||
"secciones": 76,
|
||||
"airbnb_listings": 0,
|
||||
"airbnb_entire_homes": 0,
|
||||
"airbnb_revenue_total": 0,
|
||||
"airbnb_hosts_unicos": 0,
|
||||
"airbnb_con_licencia": 0,
|
||||
"hut_registros": 2364,
|
||||
"hut_plazas": 18184
|
||||
},
|
||||
{
|
||||
"isla": "Formentera",
|
||||
"secciones": 6,
|
||||
"airbnb_listings": 0,
|
||||
"airbnb_entire_homes": 0,
|
||||
"airbnb_revenue_total": 0,
|
||||
"airbnb_hosts_unicos": 0,
|
||||
"airbnb_con_licencia": 0,
|
||||
"hut_registros": 1375,
|
||||
"hut_plazas": 14935
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 655 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 749 B |
Binary file not shown.
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="115" height="48"><path fill="#17191E" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="url(#a)" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="#17191E" d="M.02 30.31s4.02-1.95 8.05-1.95l3.04-9.4c.11-.45.44-.76.82-.76.37 0 .7.31.82.76l3.04 9.4c4.77 0 8.05 1.95 8.05 1.95L17 11.71c-.2-.56-.53-.91-.98-.91H7.83c-.44 0-.76.35-.97.9L.02 30.31Zm42.37-5.97c0 1.64-2.05 2.62-4.88 2.62-1.85 0-2.5-.45-2.5-1.41 0-1 .8-1.49 2.65-1.49 1.67 0 3.09.03 4.73.23v.05Zm.03-2.04a21.37 21.37 0 0 0-4.37-.36c-5.32 0-7.82 1.25-7.82 4.18 0 3.04 1.71 4.2 5.68 4.2 3.35 0 5.63-.84 6.46-2.92h.14c-.03.5-.05 1-.05 1.4 0 1.07.18 1.16 1.06 1.16h4.15a16.9 16.9 0 0 1-.36-4c0-1.67.06-2.93.06-4.62 0-3.45-2.07-5.64-8.56-5.64-2.8 0-5.9.48-8.26 1.19.22.93.54 2.83.7 4.06 2.04-.96 4.95-1.37 7.2-1.37 3.11 0 3.97.71 3.97 2.15v.57Zm11.37 3c-.56.07-1.33.07-2.12.07-.83 0-1.6-.03-2.12-.1l-.02.58c0 2.85 1.87 4.52 8.45 4.52 6.2 0 8.2-1.64 8.2-4.55 0-2.74-1.33-4.09-7.2-4.39-4.58-.2-4.99-.7-4.99-1.28 0-.66.59-1 3.65-1 3.18 0 4.03.43 4.03 1.35v.2a46.13 46.13 0 0 1 4.24.03l.02-.55c0-3.36-2.8-4.46-8.2-4.46-6.08 0-8.13 1.49-8.13 4.39 0 2.6 1.64 4.23 7.48 4.48 4.3.14 4.77.62 4.77 1.28 0 .7-.7 1.03-3.71 1.03-3.47 0-4.35-.48-4.35-1.47v-.13Zm19.82-12.05a17.5 17.5 0 0 1-6.24 3.48c.03.84.03 2.4.03 3.24l1.5.02c-.02 1.63-.04 3.6-.04 4.9 0 3.04 1.6 5.32 6.58 5.32 2.1 0 3.5-.23 5.23-.6a43.77 43.77 0 0 1-.46-4.13c-1.03.34-2.34.53-3.78.53-2 0-2.82-.55-2.82-2.13 0-1.37 0-2.65.03-3.84 2.57.02 5.13.07 6.64.11-.02-1.18.03-2.9.1-4.04-2.2.04-4.65.07-6.68.07l.07-2.93h-.16Zm13.46 6.04a767.33 767.33 0 0 1 .07-3.18H82.6c.07 1.96.07 3.98.07 6.92 0 2.95-.03 4.99-.07 6.93h5.18c-.09-1.37-.11-3.68-.11-5.65 0-3.1 1.26-4 4.12-4 1.33 0 2.28.16 3.1.46.03-1.16.26-3.43.4-4.43-.86-.25-1.81-.41-2.96-.41-2.46-.03-4.26.98-5.1 3.38l-.17-.02Zm22.55 3.65c0 2.5-1.8 3.66-4.64 3.66-2.81 0-4.61-1.1-4.61-3.66s1.82-3.52 4.61-3.52c2.82 0 4.64 1.03 4.64 3.52Zm4.71-.11c0-4.96-3.87-7.18-9.35-7.18-5.5 0-9.23 2.22-9.23 7.18 0 4.94 3.49 7.59 9.21 7.59 5.77 0 9.37-2.65 9.37-7.6Z"/><defs><linearGradient id="a" x1="6.33" x2="19.43" y1="40.8" y2="34.6" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1024" fill="none"><path fill="url(#a)" fill-rule="evenodd" d="M-217.58 475.75c91.82-72.02 225.52-29.38 341.2-44.74C240 415.56 372.33 315.14 466.77 384.9c102.9 76.02 44.74 246.76 90.31 366.31 29.83 78.24 90.48 136.14 129.48 210.23 57.92 109.99 169.67 208.23 155.9 331.77-13.52 121.26-103.42 264.33-224.23 281.37-141.96 20.03-232.72-220.96-374.06-196.99-151.7 25.73-172.68 330.24-325.85 315.72-128.6-12.2-110.9-230.73-128.15-358.76-12.16-90.14 65.87-176.25 44.1-264.57-26.42-107.2-167.12-163.46-176.72-273.45-10.15-116.29 33.01-248.75 124.87-320.79Z" clip-rule="evenodd" style="opacity:.154"/><path fill="url(#b)" fill-rule="evenodd" d="M1103.43 115.43c146.42-19.45 275.33-155.84 413.5-103.59 188.09 71.13 409 212.64 407.06 413.88-1.94 201.25-259.28 278.6-414.96 405.96-130 106.35-240.24 294.39-405.6 265.3-163.7-28.8-161.93-274.12-284.34-386.66-134.95-124.06-436-101.46-445.82-284.6-9.68-180.38 247.41-246.3 413.54-316.9 101.01-42.93 207.83 21.06 316.62 6.61Z" clip-rule="evenodd" style="opacity:.154"/><defs><linearGradient id="b" x1="373" x2="1995.44" y1="1100" y2="118.03" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient><linearGradient id="a" x1="107.37" x2="1130.66" y1="1993.35" y2="1026.31" gradientUnits="userSpaceOnUse"><stop stop-color="#3245FF"/><stop offset="1" stop-color="#BC52EE"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,16 @@
|
||||
---
|
||||
interface Props {
|
||||
valor: string;
|
||||
label: string;
|
||||
sub?: string;
|
||||
}
|
||||
const { valor, label, sub } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="border border-stone-200 rounded-lg p-5 bg-white">
|
||||
<p class="text-3xl sm:text-4xl font-bold text-stone-900 tabular-nums mb-1">
|
||||
{valor}
|
||||
</p>
|
||||
<p class="text-sm font-medium text-stone-800">{label}</p>
|
||||
{sub && <p class="text-xs text-stone-500 mt-1">{sub}</p>}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import { t, type Lang } from "../i18n";
|
||||
interface Props {
|
||||
lang?: Lang;
|
||||
}
|
||||
const { lang: langProp } = Astro.props;
|
||||
const lang: Lang = langProp ?? "ca";
|
||||
---
|
||||
|
||||
<footer class="border-t border-stone-200 bg-stone-50 mt-16">
|
||||
<div class="max-w-6xl mx-auto px-4 py-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<p class="text-xs text-stone-600">{t(lang, "site.footer_note")}</p>
|
||||
<p class="text-xs text-stone-500">
|
||||
<a href="/metodologia" class="hover:text-orange-700 underline underline-offset-2">
|
||||
{t(lang, "nav.methodology")}
|
||||
</a>
|
||||
· CC BY-SA 4.0
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
import { t, type Lang } from "../i18n";
|
||||
import LanguageToggle from "./LanguageToggle.astro";
|
||||
|
||||
interface Props {
|
||||
lang?: Lang;
|
||||
active: "home" | "map" | "methodology";
|
||||
}
|
||||
|
||||
const { lang: langProp, active } = Astro.props;
|
||||
const lang: Lang = langProp ?? "ca";
|
||||
const path = Astro.url.pathname;
|
||||
const isHome = path === "/" || path === "";
|
||||
const isMap = path.startsWith("/mapa");
|
||||
const isMetod = path.startsWith("/metodologia");
|
||||
|
||||
const navLink = (key: "home" | "map" | "methodology") =>
|
||||
`text-sm font-medium px-3 py-1.5 rounded transition-colors ${
|
||||
active === key
|
||||
? "text-orange-700 bg-orange-50"
|
||||
: "text-stone-700 hover:bg-stone-100"
|
||||
}`;
|
||||
---
|
||||
|
||||
<header class="border-b border-stone-200 bg-white sticky top-0 z-40">
|
||||
<div class="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between gap-4">
|
||||
<a href="/" class="flex items-baseline gap-2 group">
|
||||
<span class="text-lg font-bold text-stone-900 group-hover:text-orange-700">
|
||||
{t(lang, "site.title")}
|
||||
</span>
|
||||
<span class="text-xs text-stone-500 hidden md:inline">
|
||||
{t(lang, "site.tagline")}
|
||||
</span>
|
||||
</a>
|
||||
<nav class="flex items-center gap-1">
|
||||
<a
|
||||
href="/"
|
||||
class={navLink("home")}
|
||||
aria-current={isHome ? "page" : undefined}
|
||||
>
|
||||
{t(lang, "nav.home")}
|
||||
</a>
|
||||
<a
|
||||
href="/mapa"
|
||||
class={navLink("map")}
|
||||
aria-current={isMap ? "page" : undefined}
|
||||
>
|
||||
{t(lang, "nav.map")}
|
||||
</a>
|
||||
<a
|
||||
href="/metodologia"
|
||||
class={navLink("methodology")}
|
||||
aria-current={isMetod ? "page" : undefined}
|
||||
>
|
||||
{t(lang, "nav.methodology")}
|
||||
</a>
|
||||
<span class="ml-2 pl-2 border-l border-stone-200">
|
||||
<LanguageToggle />
|
||||
</span>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
import { getLangFromCookies, t, type Lang } from "../i18n";
|
||||
|
||||
const cookieHeader = Astro.request.headers.get("cookie");
|
||||
const lang: Lang = getLangFromCookies(cookieHeader);
|
||||
const other: Lang = lang === "ca" ? "es" : "ca";
|
||||
const toggleLabel = t(lang, "lang_toggle.to_" + other);
|
||||
---
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-stone-500 hidden sm:inline">
|
||||
{t(lang, "lang_toggle.label")}:
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-lang-toggle={other}
|
||||
aria-label={toggleLabel}
|
||||
class="text-xs font-medium px-2 py-1 border border-stone-300 rounded hover:bg-stone-100 transition-colors"
|
||||
>
|
||||
{toggleLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("click", (ev) => {
|
||||
const target = ev.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
const btn = target.closest("[data-lang-toggle]") as HTMLElement | null;
|
||||
if (!btn) return;
|
||||
const next = btn.getAttribute("data-lang-toggle");
|
||||
if (next !== "ca" && next !== "es") return;
|
||||
const oneYear = 60 * 60 * 24 * 365;
|
||||
document.cookie = `lang=${next};path=/;max-age=${oneYear};samesite=lax`;
|
||||
try {
|
||||
localStorage.setItem("lang", next);
|
||||
} catch {}
|
||||
location.reload();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,411 @@
|
||||
---
|
||||
import { getLangFromAstroCookies, t, type Lang } from "../i18n";
|
||||
|
||||
const lang: Lang = getLangFromAstroCookies(Astro.cookies);
|
||||
|
||||
const filterOptions = [
|
||||
{ value: "all", label: t(lang, "map.isla_all") },
|
||||
{ value: "Mallorca", label: "Mallorca" },
|
||||
{ value: "Menorca", label: "Menorca" },
|
||||
{ value: "Eivissa", label: "Eivissa" },
|
||||
{ value: "Formentera", label: "Formentera" },
|
||||
];
|
||||
|
||||
const popupTitleTmpl = t(lang, "map.popup_title");
|
||||
---
|
||||
|
||||
<section class="border-b border-stone-200 bg-white">
|
||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-stone-900 mb-2">
|
||||
{t(lang, "map.title")}
|
||||
</h1>
|
||||
<p class="text-stone-600">{t(lang, "map.lead")}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
id="map-shell"
|
||||
class="relative w-full"
|
||||
style="height: calc(100vh - 60px); min-height: 480px;"
|
||||
data-lang={lang}
|
||||
data-popup-title-tmpl={popupTitleTmpl}
|
||||
>
|
||||
<div id="map" class="absolute inset-0"></div>
|
||||
|
||||
<div
|
||||
id="map-loading"
|
||||
class="absolute inset-0 flex items-center justify-center bg-stone-100 text-stone-600 z-30"
|
||||
>
|
||||
{t(lang, "map.loading")}
|
||||
</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"
|
||||
>
|
||||
<div class="mb-2">
|
||||
<label for="isla-filter" class="block text-xs font-medium text-stone-700 mb-1">
|
||||
{t(lang, "map.isla_filter_label")}
|
||||
</label>
|
||||
<select
|
||||
id="isla-filter"
|
||||
class="w-full text-sm border border-stone-300 rounded px-2 py-1 bg-white"
|
||||
>
|
||||
{filterOptions.map((o) => (
|
||||
<option value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</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")}
|
||||
</p>
|
||||
<label class="flex items-center gap-2 text-xs py-0.5 cursor-pointer">
|
||||
<input type="checkbox" id="toggle-choropleth" checked />
|
||||
<span>{t(lang, "map.layer_choropleth")}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-xs py-0.5 cursor-pointer">
|
||||
<input type="checkbox" id="toggle-clusters" checked />
|
||||
<span>{t(lang, "map.layer_clusters")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-3 right-3 z-20 bg-white/95 backdrop-blur border border-stone-200 rounded-lg shadow-sm p-3 max-w-xs text-xs"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import maplibregl, { Popup } from "maplibre-gl";
|
||||
import { Protocol } from "pmtiles";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
const protocol = new Protocol();
|
||||
maplibregl.addProtocol("pmtiles", protocol.tile);
|
||||
|
||||
const shell = document.getElementById("map-shell") as HTMLElement;
|
||||
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",
|
||||
listings: "Listings Airbnb",
|
||||
hut_registros: "Registres HUT",
|
||||
hut_plazas: "Places HUT",
|
||||
granularitat: "Granularitat",
|
||||
sense_dades: "Sense dades",
|
||||
seccion_censal: "secció censal",
|
||||
municipio: "municipi",
|
||||
illa: "illa",
|
||||
list: "Llista",
|
||||
};
|
||||
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",
|
||||
};
|
||||
const T = lang === "es" ? t_es : t_ca;
|
||||
const fmt = (n: number) =>
|
||||
new Intl.NumberFormat(lang === "ca" ? "ca-ES" : "es-ES").format(n);
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: "map",
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {
|
||||
cases: {
|
||||
type: "vector",
|
||||
url: "pmtiles:///tiles/dataset.pmtiles",
|
||||
attribution: "INE + Inside Airbnb + Govern d'Eivissa + IBESTAT",
|
||||
},
|
||||
airbnb: {
|
||||
type: "geojson",
|
||||
data: "/data/airbnb_listings.geojson",
|
||||
cluster: true,
|
||||
clusterMaxZoom: 12,
|
||||
clusterRadius: 40,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: "background",
|
||||
type: "background",
|
||||
paint: { "background-color": "#f5f5f4" },
|
||||
},
|
||||
{
|
||||
id: "secciones-line",
|
||||
type: "line",
|
||||
source: "cases",
|
||||
"source-layer": "cases",
|
||||
paint: {
|
||||
"line-color": "#a8a29e",
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
6, 0.1, 10, 0.3, 13, 0.6,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "secciones-airbnb-fill",
|
||||
type: "fill",
|
||||
source: "cases",
|
||||
"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-opacity": 0.7,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "secciones-hut-fill",
|
||||
type: "fill",
|
||||
source: "cases",
|
||||
"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-opacity": 0.7,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "airbnb-clusters",
|
||||
type: "circle",
|
||||
source: "airbnb",
|
||||
filter: ["has", "point_count"],
|
||||
paint: {
|
||||
"circle-color": [
|
||||
"step", ["get", "point_count"],
|
||||
"#fde68a", 10, "#fbbf24", 50, "#f97316",
|
||||
200, "#c2410c", 1000, "#7c2d12",
|
||||
],
|
||||
"circle-radius": [
|
||||
"step", ["get", "point_count"],
|
||||
14, 10, 18, 50, 22, 200, 28, 1000, 34,
|
||||
],
|
||||
"circle-stroke-color": "#fff",
|
||||
"circle-stroke-width": 1.5,
|
||||
"circle-opacity": 0.85,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "airbnb-cluster-count",
|
||||
type: "symbol",
|
||||
source: "airbnb",
|
||||
filter: ["has", "point_count"],
|
||||
layout: {
|
||||
"text-field": ["get", "point_count_abbreviated"],
|
||||
"text-size": 12,
|
||||
"text-font": ["Open Sans Regular"],
|
||||
},
|
||||
paint: { "text-color": "#1c1917" },
|
||||
},
|
||||
{
|
||||
id: "airbnb-unclustered",
|
||||
type: "circle",
|
||||
source: "airbnb",
|
||||
filter: ["!", ["has", "point_count"]],
|
||||
paint: {
|
||||
"circle-color": "#dc2626",
|
||||
"circle-radius": 3,
|
||||
"circle-opacity": 0.7,
|
||||
"circle-stroke-color": "#fff",
|
||||
"circle-stroke-width": 0.5,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
center: [2.85, 39.6],
|
||||
zoom: 8,
|
||||
minZoom: 7,
|
||||
maxZoom: 14,
|
||||
});
|
||||
|
||||
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
|
||||
map.addControl(new maplibregl.ScaleControl({ unit: "metric" }), "bottom-left");
|
||||
|
||||
map.on("load", () => {
|
||||
const loading = document.getElementById("map-loading");
|
||||
if (loading) loading.style.display = "none";
|
||||
|
||||
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) => {
|
||||
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__"]);
|
||||
}
|
||||
};
|
||||
|
||||
const select = document.getElementById("isla-filter") as HTMLSelectElement;
|
||||
select?.addEventListener("change", () => applyFilter(select.value));
|
||||
if (select) applyFilter(select.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);
|
||||
},
|
||||
);
|
||||
});
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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("click", "airbnb-clusters", async (e) => {
|
||||
const features = map.queryRenderedFeatures(e.point, { layers: ["airbnb-clusters"] });
|
||||
const clusterId = features[0]?.properties?.cluster_id;
|
||||
if (clusterId == null) return;
|
||||
const src = map.getSource("airbnb") as maplibregl.GeoJSONSource;
|
||||
const zoom = await src.getClusterExpansionZoom(clusterId as number);
|
||||
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 = "";
|
||||
});
|
||||
});
|
||||
|
||||
function openPopup(e: maplibregl.MapLayerMouseEvent, kind: "airbnb" | "hut") {
|
||||
const f = e.features?.[0];
|
||||
if (!f) return;
|
||||
const p = f.properties ?? {};
|
||||
const cusec = String(p.CUSEC ?? "—");
|
||||
const title = popupTitleTmpl.replace("{cusec}", cusec);
|
||||
const gran = String(p.granularidad ?? "—");
|
||||
const granLabel = T[gran] ?? gran;
|
||||
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>`);
|
||||
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>`);
|
||||
} 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(`<div class="text-[10px] text-stone-500 mt-1 italic">${T.granularitat}: ${granLabel}</div>`);
|
||||
new Popup({ closeButton: true })
|
||||
.setLngLat(e.lngLat)
|
||||
.setHTML(lines.join(""))
|
||||
.addTo(map);
|
||||
}
|
||||
|
||||
function escape(s: string) {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,57 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface IslaStats {
|
||||
isla: string;
|
||||
secciones: number;
|
||||
airbnb_listings: number;
|
||||
airbnb_entire_homes: number;
|
||||
airbnb_revenue_total: number;
|
||||
airbnb_hosts_unicos: number;
|
||||
airbnb_con_licencia: number;
|
||||
hut_registros: number;
|
||||
hut_plazas: number;
|
||||
}
|
||||
|
||||
export interface WebDataset {
|
||||
resumen_por_isla: IslaStats[];
|
||||
}
|
||||
|
||||
function load(): WebDataset {
|
||||
const file = path.resolve(
|
||||
process.cwd(),
|
||||
"public/data/dataset_web.json",
|
||||
);
|
||||
const raw = fs.readFileSync(file, "utf-8");
|
||||
return JSON.parse(raw) as WebDataset;
|
||||
}
|
||||
|
||||
let cached: WebDataset | null = null;
|
||||
export function getDataset(): WebDataset {
|
||||
if (!cached) cached = load();
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function totals(d: WebDataset) {
|
||||
const t = {
|
||||
airbnb_listings: 0,
|
||||
airbnb_entire_homes: 0,
|
||||
airbnb_revenue_total: 0,
|
||||
airbnb_hosts_unicos: 0,
|
||||
airbnb_con_licencia: 0,
|
||||
hut_registros: 0,
|
||||
hut_plazas: 0,
|
||||
secciones: 0,
|
||||
};
|
||||
for (const r of d.resumen_por_isla) {
|
||||
t.airbnb_listings += r.airbnb_listings;
|
||||
t.airbnb_entire_homes += r.airbnb_entire_homes;
|
||||
t.airbnb_revenue_total += r.airbnb_revenue_total;
|
||||
t.airbnb_hosts_unicos += r.airbnb_hosts_unicos;
|
||||
t.airbnb_con_licencia += r.airbnb_con_licencia;
|
||||
t.hut_registros += r.hut_registros;
|
||||
t.hut_plazas += r.hut_plazas;
|
||||
t.secciones += r.secciones;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"site": {
|
||||
"title": "Cases Tancades",
|
||||
"tagline": "Vivenda tancada, illes saturades",
|
||||
"description": "Creuem dades oficials d'habitatge semi-buit amb lloguer turístic a les Illes Balears. On és la gent que hauria d'habitar aquests pisos?",
|
||||
"footer_note": "Dades públiques, metodologia oberta. Sense acusació individual."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Inici",
|
||||
"map": "Mapa",
|
||||
"methodology": "Metodologia"
|
||||
},
|
||||
"lang_toggle": {
|
||||
"label": "Idioma",
|
||||
"to_es": "Castellano",
|
||||
"to_ca": "Català"
|
||||
},
|
||||
"home": {
|
||||
"hero_eyebrow": "Diagnòstic obert de l'habitatge a Balears",
|
||||
"hero_title": "Quants pisos tancats hi ha a les Balears?",
|
||||
"hero_subtitle": "Creuem dades oficials d'habitatge semi-buit amb lloguer turístic. La diferència entre el que és legal i el que passa al carrer és la denúncia.",
|
||||
"cta_map": "Obrir el mapa interactiu",
|
||||
"cta_methodology": "Com hem fet el càlcul?",
|
||||
"section_evidence_title": "Les xifres que no surten a la premsa",
|
||||
"section_evidence_lead": "Cada xifra ve d'una font pública. Cap estimació opaca.",
|
||||
"stat_airbnb_label": "Listings actius a Airbnb",
|
||||
"stat_airbnb_sub": "Mallorca + Menorca (geo-localitzats)",
|
||||
"stat_plazas_label": "Places turístiques",
|
||||
"stat_plazas_sub": "Entre HUT i Airbnb, a totes les illes",
|
||||
"stat_hut_label": "Places HUT legals a Eivissa",
|
||||
"stat_hut_sub": "Segons el registre del Govern",
|
||||
"stat_legal_share_label": "Concentració a Sant Josep",
|
||||
"stat_legal_share_sub": "Del total de places HUT legals d'Eivissa",
|
||||
"stat_vacant_label": "Habitatges buits a Balears",
|
||||
"stat_vacant_sub": "Dada INE, nivell CCAA",
|
||||
"stat_sporadic_label": "Ús esporàdic",
|
||||
"stat_sporadic_sub": "Habitatges potencialment ociosos",
|
||||
"narrative_title": "El que sabem, el que no sabem",
|
||||
"narrative_p1": "Hi ha 18.175 pisos oferts a Airbnb només a Mallorca i Menorca. Si cada pis allotja 4 persones de mitjana, parlem de 105.738 places turístiques — gairebé la meitat de la població de Palma.",
|
||||
"narrative_p2": "A Eivissa, el Govern reconeix 18.184 places amb llicència HUT. Però el 40% es concentren a Sant Josep de sa Talaia, un municipi rural. A Eivissa capital — 50.000 habitants — només 41 registres amb 236 places.",
|
||||
"narrative_p3": "La pregunta no és si hi ha pisos buits. La pregunta és on són les 7.000 places que s'ofereixen a Airbnb a Eivissa si el registre legal només en compta 236 al casc urbà.",
|
||||
"section_per_island_title": "Per illa",
|
||||
"col_island": "Illa",
|
||||
"col_sections": "Seccions",
|
||||
"col_listings": "Listings Airbnb",
|
||||
"col_hut": "Registres HUT",
|
||||
"col_hut_places": "Places HUT",
|
||||
"section_story_title": "L'evidència que no es pot ignorar",
|
||||
"story_card1_title": "Mallorca",
|
||||
"story_card1_body": "14.644 listings actius a Airbnb, 13.925 dels quals són pisos sencers. Un de cada quatre pisos sencers que es lloguen a Mallorca estan a Airbnb.",
|
||||
"story_card2_title": "Menorca",
|
||||
"story_card2_body": "3.531 listings actius. La pressió és forta a Ciutadella i Maó, però el pati s'ha omplert de lloguer temporal.",
|
||||
"story_card3_title": "Eivissa",
|
||||
"story_card3_body": "2.364 HUT legals, 18.184 places. La xifra oficial — la realitat al carrer és molt més gran.",
|
||||
"story_card4_title": "Formentera",
|
||||
"story_card4_body": "1.375 establiments, 14.935 places. Una illa sencera de 12.000 habitants amb més places turístiques que residents."
|
||||
},
|
||||
"map": {
|
||||
"title": "Mapa interactiu",
|
||||
"lead": "Cada secció censal pintada segons la pressió turística. Fes clic per veure les dades de la secció.",
|
||||
"isla_filter_label": "Filtra per illa",
|
||||
"isla_all": "Totes",
|
||||
"layer_label": "Capa",
|
||||
"layer_choropleth": "Plànol coroplètic",
|
||||
"layer_clusters": "Punts Airbnb (clústers)",
|
||||
"legend_title": "Llegenda",
|
||||
"legend_airbnb_title": "Mallorca / Menorca",
|
||||
"legend_airbnb_desc": "Listings actius a Airbnb per secció censal",
|
||||
"legend_hut_title": "Eivissa / Formentera",
|
||||
"legend_hut_desc": "Places HUT legals per municipi (replicat a totes les seccions del municipi)",
|
||||
"popup_title": "Secció {cusec}",
|
||||
"popup_municipi": "Municipi",
|
||||
"popup_isla": "Illa",
|
||||
"popup_listings": "Listings Airbnb",
|
||||
"popup_hut_registros": "Registres HUT",
|
||||
"popup_hut_plazas": "Places HUT",
|
||||
"popup_granularitat": "Granularitat",
|
||||
"popup_no_data": "Sense dades",
|
||||
"loading": "Carregant mapa…"
|
||||
},
|
||||
"methodology": {
|
||||
"title": "Metodologia",
|
||||
"lead": "Com hem fet aquesta anàlisi. Fonts, dates, limitacions.",
|
||||
"section_sources_title": "Fonts",
|
||||
"src_ine_title": "INE (Institut Nacional d'Estadística)",
|
||||
"src_ine_body": "Dades de consum elèctric per intensitat d'ús (tabla 59531) i percentatge d'habitatges turístics (tabla 39365). Cobertura: CCAA Balears.",
|
||||
"src_airbnb_title": "Inside Airbnb (Mallorca i Menorca)",
|
||||
"src_airbnb_body": "Snapshots mensuals de listings georreferenciats. Filtre: availability_365 > 0 o room_type == 'Entire home/apt'. Cobertura: secció censal.",
|
||||
"src_hut_title": "Registre HUT del Consell d'Eivissa",
|
||||
"src_hut_body": "Descàrrega del 14 de juliol de 2026. Cobertura: municipi (5 caràcters de la referència cadastral).",
|
||||
"src_ibestat_title": "IBESTAT (Formentera)",
|
||||
"src_ibestat_body": "Establiments i places turístiques agregades, 2019. Cobertura: illa.",
|
||||
"src_shapefile_title": "Shapefile de seccions censals (INE 2025)",
|
||||
"src_shapefile_body": "674 seccions censals de les Illes Balears. CRS WGS84.",
|
||||
"section_granularity_title": "Per què dues escales al mapa?",
|
||||
"granularity_p1": "Mallorca i Menorca tenim dades per secció censal (500–2.000 llars). El color del mapa en aquestes illes reflecteix els listings actius d'Airbnb per secció.",
|
||||
"granularity_p2": "A Eivissa i Formentera només tenim dades agregades a nivell de municipi o d'illa. Per tant, totes les seccions censals d'un municipi comparteixen el mateix valor — la variació que veus al mapa és entre municipis, no entre barris.",
|
||||
"granularity_p3": "Això vol dir que la comparativa entre illes és entre peres i pomes. La lectura correcta és: dins de cada illa, on es concentra la pressió?",
|
||||
"section_limits_title": "Limitacions",
|
||||
"limit_ine_ccaa_title": "L'habitatge semi-buit és a nivell CCAA, no secció",
|
||||
"limit_ine_ccaa_body": "El percentatge de pisos buits (16.2%) i d'ús esporàdic (6.9%) és el mateix per a totes les seccions del mapa. És la millor dada pública disponible, però no es pot creuar amb la secció censal.",
|
||||
"limit_airbnb_title": "Airbnb és una cota inferior",
|
||||
"limit_airbnb_body": "Molts pisos turístics no són a Airbnb. A Eivissa la diferència entre el registre legal i la realitat pot ser molt més gran del que mostrem.",
|
||||
"limit_scrape_title": "No hem fet scraping",
|
||||
"limit_scrape_body": "Cap dada prové de scraping directe d'Idealista o Airbnb. Només fonts oficials o obertes (Inside Airbnb, INE, Govern, IBESTAT).",
|
||||
"limit_license_title": "El camp 'license' d'Airbnb és autorreportat",
|
||||
"limit_license_body": "El 98.8% dels listings de Mallorca i Menorca tenen número de llicència. Però el Govern no valida aquest camp — el que sembla legalitat pot no ser-ho.",
|
||||
"section_snapshots_title": "Dates de les dades",
|
||||
"snap_airbnb": "Inside Airbnb Mallorca / Menorca: 2026-06",
|
||||
"snap_hut": "HUT Consell d'Eivissa: 2026-07-14",
|
||||
"snap_ibestat": "IBESTAT Formentera: 2019 (última publicació)",
|
||||
"snap_ine": "INE consum elèctric: 2024; vivenda turística: 2025-05",
|
||||
"snap_shapefile": "Seccions censals: 2025",
|
||||
"section_license_title": "Llicència i autoria",
|
||||
"license_body": "Codi i dades publicats sota CC BY-SA 4.0. Pots reutilitzar, adaptar i difondre amb atribució i la mateixa llicència.",
|
||||
"contact": "Contacte",
|
||||
"snap_grid_label": "Dada",
|
||||
"snap_grid_value": "Data"
|
||||
},
|
||||
"common": {
|
||||
"and": "i",
|
||||
"of_total": "del total",
|
||||
"people": "persones",
|
||||
"back_to_home": "Tornar a l'inici"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"site": {
|
||||
"title": "Casas Cerradas",
|
||||
"tagline": "Vivienda cerrada, islas saturadas",
|
||||
"description": "Cruzamos datos oficiales de vivienda semi-vacía con alquiler turístico en las Islas Baleares. ¿Dónde está la gente que debería habitar estos pisos?",
|
||||
"footer_note": "Datos públicos, metodología abierta. Sin acusación individual."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Inicio",
|
||||
"map": "Mapa",
|
||||
"methodology": "Metodología"
|
||||
},
|
||||
"lang_toggle": {
|
||||
"label": "Idioma",
|
||||
"to_es": "Castellano",
|
||||
"to_ca": "Català"
|
||||
},
|
||||
"home": {
|
||||
"hero_eyebrow": "Diagnóstico abierto de la vivienda en Baleares",
|
||||
"hero_title": "¿Cuántos pisos cerrados hay en Baleares?",
|
||||
"hero_subtitle": "Cruzamos datos oficiales de vivienda semi-vacía con alquiler turístico. La diferencia entre lo legal y lo que pasa en la calle es la denuncia.",
|
||||
"cta_map": "Abrir el mapa interactivo",
|
||||
"cta_methodology": "¿Cómo hemos hecho el cálculo?",
|
||||
"section_evidence_title": "Las cifras que no salen en la prensa",
|
||||
"section_evidence_lead": "Cada cifra viene de una fuente pública. Ninguna estimación opaca.",
|
||||
"stat_airbnb_label": "Listings activos en Airbnb",
|
||||
"stat_airbnb_sub": "Mallorca + Menorca (geo-localizados)",
|
||||
"stat_plazas_label": "Plazas turísticas",
|
||||
"stat_plazas_sub": "Entre HUT y Airbnb, en todas las islas",
|
||||
"stat_hut_label": "Plazas HUT legales en Ibiza",
|
||||
"stat_hut_sub": "Según el registro del Govern",
|
||||
"stat_legal_share_label": "Concentración en Sant Josep",
|
||||
"stat_legal_share_sub": "Del total de plazas HUT legales de Ibiza",
|
||||
"stat_vacant_label": "Viviendas vacías en Baleares",
|
||||
"stat_vacant_sub": "Dato INE, nivel CCAA",
|
||||
"stat_sporadic_label": "Uso esporádico",
|
||||
"stat_sporadic_sub": "Viviendas potencialmente ociosas",
|
||||
"narrative_title": "Lo que sabemos, lo que no sabemos",
|
||||
"narrative_p1": "Hay 18.175 pisos ofertados en Airbnb solo en Mallorca y Menorca. Si cada piso aloja 4 personas de media, son 105.738 plazas turísticas — casi la mitad de la población de Palma.",
|
||||
"narrative_p2": "En Ibiza, el Govern reconoce 18.184 plazas con licencia HUT. Pero el 40% se concentran en Sant Josep de sa Talaia, un municipio rural. En Ibiza capital — 50.000 habitantes — solo 41 registros con 236 plazas.",
|
||||
"narrative_p3": "La pregunta no es si hay pisos vacíos. La pregunta es dónde están las 7.000 plazas que se ofertan en Airbnb en Ibiza si el registro legal solo cuenta 236 en el casco urbano.",
|
||||
"section_per_island_title": "Por isla",
|
||||
"col_island": "Isla",
|
||||
"col_sections": "Secciones",
|
||||
"col_listings": "Listings Airbnb",
|
||||
"col_hut": "Registros HUT",
|
||||
"col_hut_places": "Plazas HUT",
|
||||
"section_story_title": "La evidencia que no se puede ignorar",
|
||||
"story_card1_title": "Mallorca",
|
||||
"story_card1_body": "14.644 listings activos en Airbnb, 13.925 de los cuales son pisos enteros. Uno de cada cuatro pisos enteros que se alquilan en Mallorca están en Airbnb.",
|
||||
"story_card2_title": "Menorca",
|
||||
"story_card2_body": "3.531 listings activos. La presión es fuerte en Ciutadella y Maó, pero el patio se ha llenado de alquiler temporal.",
|
||||
"story_card3_title": "Ibiza",
|
||||
"story_card3_body": "2.364 HUT legales, 18.184 plazas. La cifra oficial — la realidad en la calle es mucho mayor.",
|
||||
"story_card4_title": "Formentera",
|
||||
"story_card4_body": "1.375 establecimientos, 14.935 plazas. Una isla entera de 12.000 habitantes con más plazas turísticas que residentes."
|
||||
},
|
||||
"map": {
|
||||
"title": "Mapa interactivo",
|
||||
"lead": "Cada sección censal pintada según la presión turística. Haz clic para ver los datos de la sección.",
|
||||
"isla_filter_label": "Filtra por isla",
|
||||
"isla_all": "Todas",
|
||||
"layer_label": "Capa",
|
||||
"layer_choropleth": "Plano coroplético",
|
||||
"layer_clusters": "Puntos Airbnb (clústeres)",
|
||||
"legend_title": "Leyenda",
|
||||
"legend_airbnb_title": "Mallorca / Menorca",
|
||||
"legend_airbnb_desc": "Listings activos en Airbnb por sección censal",
|
||||
"legend_hut_title": "Ibiza / Formentera",
|
||||
"legend_hut_desc": "Plazas HUT legales por municipio (replicado en todas las secciones del municipio)",
|
||||
"popup_title": "Sección {cusec}",
|
||||
"popup_municipi": "Municipio",
|
||||
"popup_isla": "Isla",
|
||||
"popup_listings": "Listings Airbnb",
|
||||
"popup_hut_registros": "Registros HUT",
|
||||
"popup_hut_plazas": "Plazas HUT",
|
||||
"popup_granularitat": "Granularidad",
|
||||
"popup_no_data": "Sin datos",
|
||||
"loading": "Cargando mapa…"
|
||||
},
|
||||
"methodology": {
|
||||
"title": "Metodología",
|
||||
"lead": "Cómo hemos hecho este análisis. Fuentes, fechas, limitaciones.",
|
||||
"section_sources_title": "Fuentes",
|
||||
"src_ine_title": "INE (Instituto Nacional de Estadística)",
|
||||
"src_ine_body": "Datos de consumo eléctrico por intensidad de uso (tabla 59531) y porcentaje de viviendas turísticas (tabla 39365). Cobertura: CCAA Baleares.",
|
||||
"src_airbnb_title": "Inside Airbnb (Mallorca y Menorca)",
|
||||
"src_airbnb_body": "Snapshots mensuales de listings georreferenciados. Filtro: availability_365 > 0 o room_type == 'Entire home/apt'. Cobertura: sección censal.",
|
||||
"src_hut_title": "Registro HUT del Consell d'Eivissa",
|
||||
"src_hut_body": "Descarga del 14 de julio de 2026. Cobertura: municipio (5 caracteres de la referencia catastral).",
|
||||
"src_ibestat_title": "IBESTAT (Formentera)",
|
||||
"src_ibestat_body": "Establecimientos y plazas turísticas agregadas, 2019. Cobertura: isla.",
|
||||
"src_shapefile_title": "Shapefile de secciones censales (INE 2025)",
|
||||
"src_shapefile_body": "674 secciones censales de las Islas Baleares. CRS WGS84.",
|
||||
"section_granularity_title": "¿Por qué dos escalas en el mapa?",
|
||||
"granularity_p1": "En Mallorca y Menorca tenemos datos por sección censal (500–2.000 hogares). El color del mapa en esas islas refleja los listings activos de Airbnb por sección.",
|
||||
"granularity_p2": "En Ibiza y Formentera solo tenemos datos agregados a nivel de municipio o isla. Por tanto, todas las secciones censales de un municipio comparten el mismo valor — la variación que ves en el mapa es entre municipios, no entre barrios.",
|
||||
"granularity_p3": "Esto significa que la comparativa entre islas es entre peras y manzanas. La lectura correcta es: dentro de cada isla, ¿dónde se concentra la presión?",
|
||||
"section_limits_title": "Limitaciones",
|
||||
"limit_ine_ccaa_title": "La vivienda semi-vacía es a nivel CCAA, no sección",
|
||||
"limit_ine_ccaa_body": "El porcentaje de pisos vacíos (16.2%) y de uso esporádico (6.9%) es el mismo para todas las secciones del mapa. Es el mejor dato público disponible, pero no se puede cruzar con la sección censal.",
|
||||
"limit_airbnb_title": "Airbnb es una cota inferior",
|
||||
"limit_airbnb_body": "Muchos pisos turísticos no están en Airbnb. En Ibiza la diferencia entre el registro legal y la realidad puede ser mucho mayor de lo que mostramos.",
|
||||
"limit_scrape_title": "No hemos hecho scraping",
|
||||
"limit_scrape_body": "Ningún dato proviene de scraping directo de Idealista o Airbnb. Solo fuentes oficiales o abiertas (Inside Airbnb, INE, Govern, IBESTAT).",
|
||||
"limit_license_title": "El campo 'license' de Airbnb es autorreportado",
|
||||
"limit_license_body": "El 98.8% de los listings de Mallorca y Menorca tienen número de licencia. Pero el Govern no valida este campo — lo que parece legalidad puede no serlo.",
|
||||
"section_snapshots_title": "Fechas de los datos",
|
||||
"snap_airbnb": "Inside Airbnb Mallorca / Menorca: 2026-06",
|
||||
"snap_hut": "HUT Consell d'Eivissa: 2026-07-14",
|
||||
"snap_ibestat": "IBESTAT Formentera: 2019 (última publicación)",
|
||||
"snap_ine": "INE consumo eléctrico: 2024; vivienda turística: 2025-05",
|
||||
"snap_shapefile": "Secciones censales: 2025",
|
||||
"section_license_title": "Licencia y autoría",
|
||||
"license_body": "Código y datos publicados bajo CC BY-SA 4.0. Puedes reutilizar, adaptar y difundir con atribución y la misma licencia.",
|
||||
"contact": "Contacto",
|
||||
"snap_grid_label": "Dato",
|
||||
"snap_grid_value": "Fecha"
|
||||
},
|
||||
"common": {
|
||||
"and": "y",
|
||||
"of_total": "del total",
|
||||
"people": "persones",
|
||||
"back_to_home": "Volver al inicio"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import ca from "./ca.json";
|
||||
import es from "./es.json";
|
||||
|
||||
export type Lang = "ca" | "es";
|
||||
export type Dict = typeof ca;
|
||||
|
||||
const DICTS: Record<Lang, Dict> = { ca, es };
|
||||
|
||||
export function getLangFromCookies(
|
||||
cookieHeader: string | null | undefined,
|
||||
): Lang {
|
||||
if (!cookieHeader) return "ca";
|
||||
const m = cookieHeader.match(/(?:^|;\s*)lang=([a-z]{2})/);
|
||||
if (m && (m[1] === "ca" || m[1] === "es")) return m[1];
|
||||
return "ca";
|
||||
}
|
||||
|
||||
export function getLangFromAstroCookies(
|
||||
cookies: { get(name: string): { value: string } | undefined },
|
||||
): Lang {
|
||||
const c = cookies.get("lang");
|
||||
if (c && (c.value === "ca" || c.value === "es")) return c.value;
|
||||
return "ca";
|
||||
}
|
||||
|
||||
export function t(lang: Lang, key: string): string {
|
||||
const dict = DICTS[lang];
|
||||
const parts = key.split(".");
|
||||
let cur: unknown = dict;
|
||||
for (const p of parts) {
|
||||
if (cur && typeof cur === "object" && p in (cur as Record<string, unknown>)) {
|
||||
cur = (cur as Record<string, unknown>)[p];
|
||||
} else {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return typeof cur === "string" ? cur : key;
|
||||
}
|
||||
|
||||
export function format(lang: Lang, key: string, vars: Record<string, string | number>): string {
|
||||
let s = t(lang, key);
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
s = s.replaceAll(`{${k}}`, String(v));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
import "../styles/global.css";
|
||||
import Header from "../components/Header.astro";
|
||||
import Footer from "../components/Footer.astro";
|
||||
import { t, type Lang } from "../i18n";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
active: "home" | "map" | "methodology";
|
||||
lang?: Lang;
|
||||
}
|
||||
|
||||
const { title, description, active, lang: langProp } = Astro.props;
|
||||
const cookieVal = Astro.cookies.get("lang")?.value;
|
||||
const lang: Lang = langProp ?? (cookieVal === "es" ? "es" : "ca");
|
||||
|
||||
const fullTitle = title ? `${title} · ${t(lang, "site.title")}` : t(lang, "site.title");
|
||||
const fullDescription = description ?? t(lang, "site.description");
|
||||
const siteTitle = t(lang, "site.title");
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="description" content={fullDescription} />
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={fullDescription} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content={siteTitle} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={fullTitle} />
|
||||
<meta name="twitter:description" content={fullDescription} />
|
||||
</head>
|
||||
<body class="bg-stone-50 text-stone-900">
|
||||
<Header lang={lang} active={active} />
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
<Footer lang={lang} />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import Base from "../layouts/Base.astro";
|
||||
import CifraImpacto from "../components/CifraImpacto.astro";
|
||||
import { getDataset, totals } from "../data/dataset";
|
||||
import { getLangFromAstroCookies, t, type Lang } from "../i18n";
|
||||
|
||||
const lang: Lang = getLangFromAstroCookies(Astro.cookies);
|
||||
|
||||
const dataset = getDataset();
|
||||
const t0 = totals(dataset);
|
||||
const eivissa = dataset.resumen_por_isla.find((r) => r.isla === "Eivissa")!;
|
||||
const santJosepShare =
|
||||
eivissa.hut_plazas > 0 ? 7296 / eivissa.hut_plazas : 0;
|
||||
const fmtInt = new Intl.NumberFormat(lang === "ca" ? "ca-ES" : "es-ES");
|
||||
const fmtPct = (x: number) => `${(x * 100).toFixed(0)}%`;
|
||||
---
|
||||
|
||||
<Base lang={lang} active="home">
|
||||
<section class="bg-gradient-to-b from-orange-50 to-stone-50 border-b border-stone-200">
|
||||
<div class="max-w-4xl mx-auto px-4 py-16 sm:py-24 text-center">
|
||||
<p class="text-xs sm:text-sm font-medium text-orange-700 uppercase tracking-wider mb-4">
|
||||
{t(lang, "home.hero_eyebrow")}
|
||||
</p>
|
||||
<h1 class="text-3xl sm:text-5xl font-bold text-stone-900 leading-tight mb-6">
|
||||
{t(lang, "home.hero_title")}
|
||||
</h1>
|
||||
<p class="text-base sm:text-lg text-stone-700 max-w-2xl mx-auto mb-8">
|
||||
{t(lang, "home.hero_subtitle")}
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<a
|
||||
href="/mapa"
|
||||
class="inline-flex items-center justify-center px-6 py-3 bg-orange-700 text-white font-medium rounded hover:bg-orange-800 transition-colors"
|
||||
>
|
||||
{t(lang, "home.cta_map")}
|
||||
</a>
|
||||
<a
|
||||
href="/metodologia"
|
||||
class="inline-flex items-center justify-center px-6 py-3 border border-stone-300 text-stone-800 font-medium rounded hover:bg-stone-100 transition-colors"
|
||||
>
|
||||
{t(lang, "home.cta_methodology")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="max-w-6xl mx-auto px-4 py-12 sm:py-16">
|
||||
<h2 class="text-2xl sm:text-3xl font-bold text-stone-900 mb-2">
|
||||
{t(lang, "home.section_evidence_title")}
|
||||
</h2>
|
||||
<p class="text-stone-600 mb-8">{t(lang, "home.section_evidence_lead")}</p>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<CifraImpacto
|
||||
valor={fmtInt.format(t0.airbnb_listings)}
|
||||
label={t(lang, "home.stat_airbnb_label")}
|
||||
sub={t(lang, "home.stat_airbnb_sub")}
|
||||
/>
|
||||
<CifraImpacto
|
||||
valor={fmtInt.format(t0.airbnb_entire_homes + eivissa.hut_registros)}
|
||||
label={t(lang, "home.stat_plazas_label")}
|
||||
sub={t(lang, "home.stat_plazas_sub")}
|
||||
/>
|
||||
<CifraImpacto
|
||||
valor={fmtInt.format(eivissa.hut_plazas)}
|
||||
label={t(lang, "home.stat_hut_label")}
|
||||
sub={t(lang, "home.stat_hut_sub")}
|
||||
/>
|
||||
<CifraImpacto
|
||||
valor={fmtPct(santJosepShare)}
|
||||
label={t(lang, "home.stat_legal_share_label")}
|
||||
sub={t(lang, "home.stat_legal_share_sub")}
|
||||
/>
|
||||
<CifraImpacto
|
||||
valor="16,2%"
|
||||
label={t(lang, "home.stat_vacant_label")}
|
||||
sub={t(lang, "home.stat_vacant_sub")}
|
||||
/>
|
||||
<CifraImpacto
|
||||
valor="6,9%"
|
||||
label={t(lang, "home.stat_sporadic_label")}
|
||||
sub={t(lang, "home.stat_sporadic_sub")}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||
<h2 class="text-2xl sm:text-3xl font-bold text-stone-900 mb-6">
|
||||
{t(lang, "home.narrative_title")}
|
||||
</h2>
|
||||
<div class="prose prose-stone max-w-none space-y-4 text-stone-800 leading-relaxed">
|
||||
<p>{t(lang, "home.narrative_p1")}</p>
|
||||
<p>{t(lang, "home.narrative_p2")}</p>
|
||||
<p class="text-lg font-medium text-stone-900 border-l-4 border-orange-700 pl-4">
|
||||
{t(lang, "home.narrative_p3")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="max-w-6xl mx-auto px-4 py-12">
|
||||
<h2 class="text-2xl sm:text-3xl font-bold text-stone-900 mb-6">
|
||||
{t(lang, "home.section_per_island_title")}
|
||||
</h2>
|
||||
<div class="overflow-x-auto border border-stone-200 rounded-lg bg-white">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-stone-100 text-stone-700">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-2 font-medium">{t(lang, "home.col_island")}</th>
|
||||
<th class="text-right px-4 py-2 font-medium">{t(lang, "home.col_sections")}</th>
|
||||
<th class="text-right px-4 py-2 font-medium">{t(lang, "home.col_listings")}</th>
|
||||
<th class="text-right px-4 py-2 font-medium">{t(lang, "home.col_hut")}</th>
|
||||
<th class="text-right px-4 py-2 font-medium">{t(lang, "home.col_hut_places")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dataset.resumen_por_isla.map((r) => (
|
||||
<tr class="border-t border-stone-200">
|
||||
<td class="px-4 py-2 font-medium">{r.isla}</td>
|
||||
<td class="px-4 py-2 text-right tabular-nums">{fmtInt.format(r.secciones)}</td>
|
||||
<td class="px-4 py-2 text-right tabular-nums">{fmtInt.format(r.airbnb_listings)}</td>
|
||||
<td class="px-4 py-2 text-right tabular-nums">{fmtInt.format(r.hut_registros)}</td>
|
||||
<td class="px-4 py-2 text-right tabular-nums">{fmtInt.format(r.hut_plazas)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="max-w-6xl mx-auto px-4 py-12">
|
||||
<h2 class="text-2xl sm:text-3xl font-bold text-stone-900 mb-6">
|
||||
{t(lang, "home.section_story_title")}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
<article class="border border-stone-200 rounded-lg p-5 bg-white">
|
||||
<h3 class="font-bold text-lg mb-2 text-orange-700">
|
||||
{t(lang, `home.story_card${n}_title`)}
|
||||
</h3>
|
||||
<p class="text-sm text-stone-700 leading-relaxed">
|
||||
{t(lang, `home.story_card${n}_body`)}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import Base from "../layouts/Base.astro";
|
||||
import Mapa from "../components/Mapa.astro";
|
||||
import { getLangFromAstroCookies, type Lang } from "../i18n";
|
||||
|
||||
const lang: Lang = getLangFromAstroCookies(Astro.cookies);
|
||||
---
|
||||
|
||||
<Base lang={lang} active="map" title={undefined} description={undefined}>
|
||||
<Mapa />
|
||||
</Base>
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import Base from "../layouts/Base.astro";
|
||||
import { getLangFromAstroCookies, t, type Lang } from "../i18n";
|
||||
|
||||
const lang: Lang = getLangFromAstroCookies(Astro.cookies);
|
||||
---
|
||||
|
||||
<Base lang={lang} active="methodology">
|
||||
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-stone-900 mb-3">
|
||||
{t(lang, "methodology.title")}
|
||||
</h1>
|
||||
<p class="text-stone-600 text-lg">{t(lang, "methodology.lead")}</p>
|
||||
</section>
|
||||
|
||||
<section class="max-w-4xl mx-auto px-4 py-6">
|
||||
<h2 class="text-2xl font-bold text-stone-900 mb-4 border-b border-stone-200 pb-2">
|
||||
{t(lang, "methodology.section_sources_title")}
|
||||
</h2>
|
||||
<div class="space-y-5">
|
||||
<article>
|
||||
<h3 class="font-semibold text-stone-900 mb-1">
|
||||
{t(lang, "methodology.src_ine_title")}
|
||||
</h3>
|
||||
<p class="text-sm text-stone-700 leading-relaxed">
|
||||
{t(lang, "methodology.src_ine_body")}
|
||||
</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3 class="font-semibold text-stone-900 mb-1">
|
||||
{t(lang, "methodology.src_airbnb_title")}
|
||||
</h3>
|
||||
<p class="text-sm text-stone-700 leading-relaxed">
|
||||
{t(lang, "methodology.src_airbnb_body")}
|
||||
</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3 class="font-semibold text-stone-900 mb-1">
|
||||
{t(lang, "methodology.src_hut_title")}
|
||||
</h3>
|
||||
<p class="text-sm text-stone-700 leading-relaxed">
|
||||
{t(lang, "methodology.src_hut_body")}
|
||||
</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3 class="font-semibold text-stone-900 mb-1">
|
||||
{t(lang, "methodology.src_ibestat_title")}
|
||||
</h3>
|
||||
<p class="text-sm text-stone-700 leading-relaxed">
|
||||
{t(lang, "methodology.src_ibestat_body")}
|
||||
</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3 class="font-semibold text-stone-900 mb-1">
|
||||
{t(lang, "methodology.src_shapefile_title")}
|
||||
</h3>
|
||||
<p class="text-sm text-stone-700 leading-relaxed">
|
||||
{t(lang, "methodology.src_shapefile_body")}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="max-w-4xl mx-auto px-4 py-6">
|
||||
<h2 class="text-2xl font-bold text-stone-900 mb-4 border-b border-stone-200 pb-2">
|
||||
{t(lang, "methodology.section_granularity_title")}
|
||||
</h2>
|
||||
<div class="space-y-3 text-stone-800 leading-relaxed">
|
||||
<p>{t(lang, "methodology.granularity_p1")}</p>
|
||||
<p>{t(lang, "methodology.granularity_p2")}</p>
|
||||
<p class="text-stone-900 font-medium border-l-4 border-orange-700 pl-4">
|
||||
{t(lang, "methodology.granularity_p3")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="max-w-4xl mx-auto px-4 py-6">
|
||||
<h2 class="text-2xl font-bold text-stone-900 mb-4 border-b border-stone-200 pb-2">
|
||||
{t(lang, "methodology.section_limits_title")}
|
||||
</h2>
|
||||
<div class="space-y-5">
|
||||
<article>
|
||||
<h3 class="font-semibold text-stone-900 mb-1">
|
||||
{t(lang, "methodology.limit_ine_ccaa_title")}
|
||||
</h3>
|
||||
<p class="text-sm text-stone-700 leading-relaxed">
|
||||
{t(lang, "methodology.limit_ine_ccaa_body")}
|
||||
</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3 class="font-semibold text-stone-900 mb-1">
|
||||
{t(lang, "methodology.limit_airbnb_title")}
|
||||
</h3>
|
||||
<p class="text-sm text-stone-700 leading-relaxed">
|
||||
{t(lang, "methodology.limit_airbnb_body")}
|
||||
</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3 class="font-semibold text-stone-900 mb-1">
|
||||
{t(lang, "methodology.limit_scrape_title")}
|
||||
</h3>
|
||||
<p class="text-sm text-stone-700 leading-relaxed">
|
||||
{t(lang, "methodology.limit_scrape_body")}
|
||||
</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3 class="font-semibold text-stone-900 mb-1">
|
||||
{t(lang, "methodology.limit_license_title")}
|
||||
</h3>
|
||||
<p class="text-sm text-stone-700 leading-relaxed">
|
||||
{t(lang, "methodology.limit_license_body")}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="max-w-4xl mx-auto px-4 py-6">
|
||||
<h2 class="text-2xl font-bold text-stone-900 mb-4 border-b border-stone-200 pb-2">
|
||||
{t(lang, "methodology.section_snapshots_title")}
|
||||
</h2>
|
||||
<div class="overflow-x-auto border border-stone-200 rounded-lg bg-white">
|
||||
<table class="w-full text-sm">
|
||||
<tbody>
|
||||
<tr class="border-b border-stone-200">
|
||||
<td class="px-4 py-2 text-stone-600">{t(lang, "methodology.snap_airbnb")}</td>
|
||||
<td class="px-4 py-2 text-right tabular-nums text-stone-900 font-medium">2026-06</td>
|
||||
</tr>
|
||||
<tr class="border-b border-stone-200">
|
||||
<td class="px-4 py-2 text-stone-600">{t(lang, "methodology.snap_hut")}</td>
|
||||
<td class="px-4 py-2 text-right tabular-nums text-stone-900 font-medium">2026-07-14</td>
|
||||
</tr>
|
||||
<tr class="border-b border-stone-200">
|
||||
<td class="px-4 py-2 text-stone-600">{t(lang, "methodology.snap_ibestat")}</td>
|
||||
<td class="px-4 py-2 text-right tabular-nums text-stone-900 font-medium">2019</td>
|
||||
</tr>
|
||||
<tr class="border-b border-stone-200">
|
||||
<td class="px-4 py-2 text-stone-600">{t(lang, "methodology.snap_ine")}</td>
|
||||
<td class="px-4 py-2 text-right tabular-nums text-stone-900 font-medium">2024 / 2025-05</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-4 py-2 text-stone-600">{t(lang, "methodology.snap_shapefile")}</td>
|
||||
<td class="px-4 py-2 text-right tabular-nums text-stone-900 font-medium">2025</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="max-w-4xl mx-auto px-4 py-6">
|
||||
<h2 class="text-2xl font-bold text-stone-900 mb-4 border-b border-stone-200 pb-2">
|
||||
{t(lang, "methodology.section_license_title")}
|
||||
</h2>
|
||||
<p class="text-sm text-stone-700 leading-relaxed mb-3">
|
||||
{t(lang, "methodology.license_body")}
|
||||
</p>
|
||||
<p class="text-sm text-stone-700 leading-relaxed">
|
||||
<span class="font-semibold">{t(lang, "methodology.contact")}:</span>
|
||||
<a
|
||||
href="https://github.com/"
|
||||
class="text-orange-700 hover:underline ml-1"
|
||||
rel="noopener"
|
||||
>github.com/...</a>
|
||||
</p>
|
||||
</section>
|
||||
</Base>
|
||||
@@ -0,0 +1,32 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-brand: #c2410c;
|
||||
--color-brand-dark: #9a3412;
|
||||
--color-ink: #1c1917;
|
||||
--color-paper: #fafaf9;
|
||||
--color-line: #e7e5e4;
|
||||
--color-soft: #f5f5f4;
|
||||
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--color-ink);
|
||||
background: var(--color-paper);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user