{"spec_id":"map-tile-background","library":"d3","language":"javascript","code":"// anyplot.ai\n// map-tile-background: Map with Tile Background\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-08-26\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data: Barcelona Bicing bike-share stations, daily rides ---------------\n// The runtime is offline (no fetch/CDN), so the \"tile background\" below is a\n// deterministic, procedurally generated street-grid basemap rather than a\n// fetched OSM/CartoDB raster — see the block-hash generator further down.\n// Pan/zoom (d3.zoom on the map group) and the provider switcher below are\n// genuine interactions in the exported HTML, not decorative placeholders.\nconst stations = [\n  { label: \"Plaça Catalunya\", lat: 41.387, lon: 2.1701, rides: 1450 },\n  { label: \"Passeig de Gràcia\", lat: 41.3917, lon: 2.1649, rides: 1280 },\n  { label: \"Rambla Catalunya\", lat: 41.39, lon: 2.162, rides: 990 },\n  { label: \"Sagrada Família\", lat: 41.4036, lon: 2.1744, rides: 980 },\n  { label: \"Barceloneta Beach\", lat: 41.3784, lon: 2.1925, rides: 1120 },\n  { label: \"Gòtic · Pl. Sant Jaume\", lat: 41.3825, lon: 2.1769, rides: 860 },\n  { label: \"Park Güell\", lat: 41.4145, lon: 2.1527, rides: 640 },\n  { label: \"El Born\", lat: 41.385, lon: 2.1815, rides: 790 },\n  { label: \"Montjuïc · Fundació Miró\", lat: 41.369, lon: 2.159, rides: 420 },\n  { label: \"Poble Sec\", lat: 41.3735, lon: 2.162, rides: 560 },\n  { label: \"Vila de Gràcia\", lat: 41.402, lon: 2.156, rides: 710 },\n  { label: \"Diagonal Mar\", lat: 41.409, lon: 2.214, rides: 480 },\n  { label: \"Port Vell\", lat: 41.3755, lon: 2.183, rides: 870 },\n  { label: \"Sant Antoni Market\", lat: 41.3757, lon: 2.1636, rides: 730 },\n];\n\n// --- Layout ------------------------------------------------------------\nconst margin = { top: 96, right: 40, bottom: 40, left: 40 };\nconst mapX0 = margin.left;\nconst mapY0 = margin.top;\nconst mapX1 = width - margin.right;\nconst mapY1 = height - margin.bottom;\nconst mapW = mapX1 - mapX0;\nconst mapH = mapY1 - mapY0;\n\n// --- Fit-bounds projection (equirectangular, latitude-corrected) -----------\n// Auto-fits the initial view to the padded data extent, the way a real tile\n// map's fitBounds() call would before picking a zoom level.\nconst lats = stations.map((d) => d.lat);\nconst lons = stations.map((d) => d.lon);\nlet latMin = d3.min(lats);\nlet latMax = d3.max(lats);\nlet lonMin = d3.min(lons);\nlet lonMax = d3.max(lons);\nconst latPad = Math.max((latMax - latMin) * 0.28, 0.008);\nconst lonPad = Math.max((lonMax - lonMin) * 0.28, 0.008);\nlatMin -= latPad;\nlatMax += latPad;\nlonMin -= lonPad;\nlonMax += lonPad;\n\nconst cosLat = Math.cos(((latMin + latMax) / 2) * (Math.PI / 180));\nconst geoW = (lonMax - lonMin) * cosLat;\nconst geoH = latMax - latMin;\nconst fitScale = Math.min(mapW / geoW, mapH / geoH);\nconst projW = geoW * fitScale;\nconst projH = geoH * fitScale;\nconst offX = mapX0 + (mapW - projW) / 2;\nconst offY = mapY0 + (mapH - projH) / 2;\n\nconst projX = (lon) => offX + (lon - lonMin) * cosLat * fitScale;\nconst projY = (lat) => offY + (latMax - lat) * fitScale;\n\nconst zoomLevel = Math.max(10, Math.min(18, Math.round(Math.log2((360 * mapW) / (256 * (lonMax - lonMin))))));\n\n// --- Basemap tones (theme-adaptive, decorative — not data colors) ----------\n// Derived from the Imprint chrome/categorical tokens by blending toward the\n// page background, instead of introducing custom hex values.\nconst blend = (hex, amt) => d3.interpolateRgb(t.pageBg, hex)(amt);\nconst providers = [\"Streets\", \"Terrain\", \"Satellite\"];\nconst tones = {\n  Streets: {\n    land: blend(t.inkSoft, 0.1),\n    block: blend(t.inkSoft, 0.18),\n    park: blend(t.palette[7], 0.22), // lime\n    water: blend(t.palette[2], 0.24), // blue\n  },\n  Terrain: {\n    land: blend(t.palette[7], 0.16), // lime\n    block: blend(t.palette[7], 0.28),\n    park: blend(t.palette[7], 0.42),\n    water: blend(t.palette[2], 0.26),\n  },\n  Satellite: {\n    land: blend(t.palette[2], 0.28), // blue\n    block: blend(t.palette[5], 0.3), // cyan\n    park: blend(t.palette[7], 0.26),\n    water: blend(t.palette[2], 0.48),\n  },\n};\nlet activeProvider = \"Streets\";\nconst streetStroke = t.grid;\n\n// --- SVG mount ---------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\nsvg\n  .append(\"clipPath\")\n  .attr(\"id\", \"map-viewport\")\n  .append(\"rect\")\n  .attr(\"x\", mapX0)\n  .attr(\"y\", mapY0)\n  .attr(\"width\", mapW)\n  .attr(\"height\", mapH)\n  .attr(\"rx\", 18);\n\nconst mapG = svg.append(\"g\").attr(\"clip-path\", \"url(#map-viewport)\");\n\n// Static land base — always fills the viewport regardless of pan/zoom, the\n// way a real tile layer falls back to a base color while tiles load.\nconst bgRect = mapG\n  .append(\"rect\")\n  .attr(\"x\", mapX0)\n  .attr(\"y\", mapY0)\n  .attr(\"width\", mapW)\n  .attr(\"height\", mapH)\n  .attr(\"fill\", tones[activeProvider].land);\n\n// Everything that should pan/zoom together lives inside this group.\nconst zoomLayer = mapG.append(\"g\").attr(\"class\", \"zoom-layer\");\n\n// --- Synthetic tile texture: deterministic street-grid basemap -------------\n// Fixed-hash pseudo-noise (no Math.random) so the \"tiles\" are reproducible.\n// Evokes Barcelona's Eixample grid blocks, with a coastline band toward the\n// bottom-right standing in for the Mediterranean. The grid extends a buffer\n// of cells beyond the viewport so panning/zooming still reveals basemap\n// texture instead of bare background.\nfunction blockHash(i, j) {\n  const s = Math.sin(i * 12.9898 + j * 78.233) * 43758.5453;\n  return s - Math.floor(s);\n}\n\nconst cell = 42;\nconst gap = 3;\nconst bufferCells = 4;\nconst cols = Math.ceil(mapW / cell) + 1 + bufferCells * 2;\nconst rows = Math.ceil(mapH / cell) + 1 + bufferCells * 2;\n\nconst blocks = [];\nfor (let j = 0; j < rows; j += 1) {\n  for (let i = 0; i < cols; i += 1) {\n    const gx = mapX0 + (i - bufferCells) * cell;\n    const gy = mapY0 + (j - bufferCells) * cell;\n    const u = (gx - mapX0) / mapW;\n    const v = (gy - mapY0) / mapH;\n    const isWater = u * 0.32 + v > 0.9;\n    const h = blockHash(i, j);\n    let kind = null;\n    if (isWater) kind = \"water\";\n    else if (h > 0.94) kind = \"park\";\n    else if (h > 0.2) kind = \"block\";\n    if (kind) blocks.push({ x: gx, y: gy, kind, isWater });\n  }\n}\n\nconst blockSel = zoomLayer\n  .selectAll(\"rect.block\")\n  .data(blocks)\n  .join(\"rect\")\n  .attr(\"class\", \"block\")\n  .attr(\"x\", (d) => d.x + (d.isWater ? 0 : gap / 2))\n  .attr(\"y\", (d) => d.y + (d.isWater ? 0 : gap / 2))\n  .attr(\"width\", (d) => cell - (d.isWater ? 0 : gap))\n  .attr(\"height\", (d) => cell - (d.isWater ? 0 : gap))\n  .attr(\"rx\", (d) => (d.isWater ? 0 : 7))\n  .attr(\"fill\", (d) => tones[activeProvider][d.kind]);\n\nconst streetLines = [];\nfor (let i = 0; i <= cols; i += 1) {\n  const gx = mapX0 + (i - bufferCells) * cell;\n  streetLines.push({ x1: gx, y1: mapY0 - bufferCells * cell, x2: gx, y2: mapY1 + bufferCells * cell });\n}\nfor (let j = 0; j <= rows; j += 1) {\n  const gy = mapY0 + (j - bufferCells) * cell;\n  streetLines.push({ x1: mapX0 - bufferCells * cell, y1: gy, x2: mapX1 + bufferCells * cell, y2: gy });\n}\n\nzoomLayer\n  .selectAll(\"line.street\")\n  .data(streetLines)\n  .join(\"line\")\n  .attr(\"class\", \"street\")\n  .attr(\"x1\", (d) => d.x1)\n  .attr(\"y1\", (d) => d.y1)\n  .attr(\"x2\", (d) => d.x2)\n  .attr(\"y2\", (d) => d.y2)\n  .attr(\"stroke\", streetStroke)\n  .attr(\"stroke-width\", 1);\n\n// --- Station markers: color-encode daily rides via imprint_seq -------------\nconst rideExtent = d3.extent(stations, (d) => d.rides);\nconst colorScale = d3.scaleSequential(d3.interpolateRgbBasis(t.seq)).domain(rideExtent);\nconst markerR = 14;\n\nzoomLayer\n  .selectAll(\"circle.station\")\n  .data(stations)\n  .join(\"circle\")\n  .attr(\"class\", \"station\")\n  .attr(\"cx\", (d) => projX(d.lon))\n  .attr(\"cy\", (d) => projY(d.lat))\n  .attr(\"r\", markerR)\n  .attr(\"fill\", (d) => colorScale(d.rides))\n  .attr(\"stroke\", t.ink)\n  .attr(\"stroke-width\", 2)\n  .attr(\"fill-opacity\", 0.92);\n\n// --- Station labels: radial placement + measured halo for legibility -------\n// The five stations in the dense central cluster (Plaça Catalunya, Passeig de\n// Gràcia, Rambla Catalunya, El Born, Gòtic) sit under 300m apart (real\n// Barcelona geography) and get explicit directional offsets, chosen to spread\n// them apart rather than relying on the generic radial push; two more crowded\n// stations (Sant Antoni Market, Poble Sec) get the same treatment.\nconst manualOffsets = {\n  \"Plaça Catalunya\": [0, -50],\n  \"Passeig de Gràcia\": [62, -20],\n  \"Rambla Catalunya\": [-62, 10],\n  \"El Born\": [55, -36],\n  \"Gòtic · Pl. Sant Jaume\": [-62, 26],\n  \"Sant Antoni Market\": [-62, -7],\n  \"Poble Sec\": [58, 26],\n};\n\nconst centerX = mapX0 + mapW / 2;\nconst centerY = mapY0 + mapH / 2;\nconst labelG = zoomLayer.append(\"g\");\n\nfor (const d of stations) {\n  const px = projX(d.lon);\n  const py = projY(d.lat);\n  let lx;\n  let ly;\n  let anchor;\n  if (manualOffsets[d.label]) {\n    const [ox, oy] = manualOffsets[d.label];\n    lx = px + ox;\n    ly = py + oy;\n    anchor = ox >= 0 ? \"start\" : \"end\";\n  } else {\n    const dx = px - centerX;\n    const dy = py - centerY;\n    const norm = Math.hypot(dx, dy) || 1;\n    lx = px + (dx / norm) * 48;\n    ly = py + (dy / norm) * 30;\n    anchor = dx >= 0 ? \"start\" : \"end\";\n  }\n\n  const grp = labelG.append(\"g\");\n  const text = grp\n    .append(\"text\")\n    .attr(\"x\", lx)\n    .attr(\"y\", ly)\n    .attr(\"text-anchor\", anchor)\n    .attr(\"dominant-baseline\", \"middle\")\n    .style(\"font-size\", \"13px\")\n    .style(\"font-weight\", \"500\")\n    .attr(\"fill\", t.ink)\n    .text(d.label);\n\n  const bbox = text.node().getBBox();\n  grp\n    .insert(\"rect\", \"text\")\n    .attr(\"x\", bbox.x - 5)\n    .attr(\"y\", bbox.y - 3)\n    .attr(\"width\", bbox.width + 10)\n    .attr(\"height\", bbox.height + 6)\n    .attr(\"rx\", 4)\n    .attr(\"fill\", t.elevatedBg)\n    .attr(\"fill-opacity\", 0.88);\n}\n\n// Map viewport frame (drawn above the zoom layer, stays fixed while panning)\nsvg\n  .append(\"rect\")\n  .attr(\"x\", mapX0)\n  .attr(\"y\", mapY0)\n  .attr(\"width\", mapW)\n  .attr(\"height\", mapH)\n  .attr(\"rx\", 18)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1.5);\n\n// --- Pan & zoom: d3.zoom() on a capture rect drives the map group ----------\nconst zoomBehavior = d3\n  .zoom()\n  .scaleExtent([1, 4])\n  .extent([\n    [mapX0, mapY0],\n    [mapX1, mapY1],\n  ])\n  .translateExtent([\n    [mapX0 - bufferCells * cell, mapY0 - bufferCells * cell],\n    [mapX1 + bufferCells * cell, mapY1 + bufferCells * cell],\n  ])\n  .on(\"zoom\", (event) => {\n    zoomLayer.attr(\"transform\", event.transform);\n  });\n\nmapG\n  .append(\"rect\")\n  .attr(\"class\", \"zoom-capture\")\n  .attr(\"x\", mapX0)\n  .attr(\"y\", mapY0)\n  .attr(\"width\", mapW)\n  .attr(\"height\", mapH)\n  .attr(\"fill\", \"transparent\")\n  .style(\"pointer-events\", \"all\")\n  .style(\"cursor\", \"grab\")\n  .call(zoomBehavior);\n\n// --- Legend: rides color scale + functional tile-provider switcher ---------\nconst legendX = mapX1 - 158;\nconst legendBarY = mapY0 + 54;\nconst legendBarH = 120;\nconst legendBoxH = 54 + legendBarH + 20 + providers.length * 22 + 12;\n\nsvg\n  .append(\"rect\")\n  .attr(\"x\", legendX - 16)\n  .attr(\"y\", mapY0 + 14)\n  .attr(\"width\", 158)\n  .attr(\"height\", legendBoxH)\n  .attr(\"rx\", 10)\n  .attr(\"fill\", t.elevatedBg)\n  .attr(\"fill-opacity\", 0.92)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1);\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", legendX)\n  .attr(\"y\", mapY0 + 34)\n  .style(\"font-size\", \"13px\")\n  .style(\"font-weight\", \"600\")\n  .attr(\"fill\", t.ink)\n  .text(\"Daily Rides\");\n\nsvg\n  .append(\"defs\")\n  .append(\"linearGradient\")\n  .attr(\"id\", \"rides-gradient\")\n  .attr(\"x1\", \"0%\")\n  .attr(\"y1\", \"100%\")\n  .attr(\"x2\", \"0%\")\n  .attr(\"y2\", \"0%\")\n  .selectAll(\"stop\")\n  .data([0, 1])\n  .join(\"stop\")\n  .attr(\"offset\", (d) => `${d * 100}%`)\n  .attr(\"stop-color\", (d) => t.seq[d]);\n\nsvg\n  .append(\"rect\")\n  .attr(\"x\", legendX)\n  .attr(\"y\", legendBarY)\n  .attr(\"width\", 14)\n  .attr(\"height\", legendBarH)\n  .attr(\"rx\", 3)\n  .attr(\"fill\", \"url(#rides-gradient)\");\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", legendX + 22)\n  .attr(\"y\", legendBarY + 6)\n  .style(\"font-size\", \"11px\")\n  .attr(\"fill\", t.inkSoft)\n  .text(rideExtent[1]);\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", legendX + 22)\n  .attr(\"y\", legendBarY + legendBarH)\n  .style(\"font-size\", \"11px\")\n  .attr(\"fill\", t.inkSoft)\n  .text(rideExtent[0]);\n\n// Tile-provider switcher — clicking a provider re-tints the basemap group\n// (bgRect + block fills) via the `tones` lookup; fully offline/deterministic,\n// no live tile fetch.\nconst providerY0 = legendBarY + legendBarH + 26;\nconst providerRows = providers.map((name, idx) => {\n  const py = providerY0 + idx * 22;\n  const row = svg.append(\"g\").style(\"cursor\", \"pointer\").on(\"click\", () => setProvider(name));\n  const swatch = row\n    .append(\"rect\")\n    .attr(\"x\", legendX - 2)\n    .attr(\"y\", py - 11)\n    .attr(\"width\", 13)\n    .attr(\"height\", 13)\n    .attr(\"rx\", 3);\n  const label = row\n    .append(\"text\")\n    .attr(\"x\", legendX + 16)\n    .attr(\"y\", py)\n    .attr(\"dominant-baseline\", \"middle\")\n    .style(\"font-size\", \"12px\");\n  return { name, swatch, label };\n});\n\nfunction setProvider(name) {\n  activeProvider = name;\n  bgRect.attr(\"fill\", tones[name].land);\n  blockSel.attr(\"fill\", (d) => tones[name][d.kind]);\n  for (const row of providerRows) {\n    const isActive = row.name === name;\n    row.swatch\n      .attr(\"fill\", isActive ? t.palette[0] : \"none\")\n      .attr(\"stroke\", isActive ? t.palette[0] : t.inkSoft)\n      .attr(\"stroke-width\", 1.5);\n    row.label\n      .style(\"font-weight\", isActive ? \"600\" : \"400\")\n      .attr(\"fill\", isActive ? t.ink : t.inkSoft)\n      .text(row.name);\n  }\n}\nsetProvider(activeProvider);\n\n// --- Auto-fit / attribution footnotes (bottom corners, inside viewport) ----\nfunction footnote(x, anchor, str) {\n  const grp = svg.append(\"g\");\n  const text = grp\n    .append(\"text\")\n    .attr(\"x\", x)\n    .attr(\"y\", mapY1 - 14)\n    .attr(\"text-anchor\", anchor)\n    .style(\"font-size\", \"11px\")\n    .attr(\"fill\", t.inkSoft)\n    .text(str);\n  const bbox = text.node().getBBox();\n  grp\n    .insert(\"rect\", \"text\")\n    .attr(\"x\", bbox.x - 6)\n    .attr(\"y\", bbox.y - 3)\n    .attr(\"width\", bbox.width + 12)\n    .attr(\"height\", bbox.height + 6)\n    .attr(\"rx\", 4)\n    .attr(\"fill\", t.elevatedBg)\n    .attr(\"fill-opacity\", 0.85);\n}\nfootnote(mapX0 + 14, \"start\", `Auto-fit to data bounds · zoom ≈ ${zoomLevel} · drag to pan, scroll to zoom`);\nfootnote(mapX1 - 14, \"end\", \"Basemap: anyplot synthetic street tiles (offline render)\");\n\n// --- Title -------------------------------------------------------------\nconst title = \"Barcelona Bicing Network · map-tile-background · javascript · d3 · anyplot.ai\";\nconst titleFontSize = Math.min(22, Math.round((22 * 67) / title.length));\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 46)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", `${titleFontSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(title);\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 72)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(\"Bike-share stations sized on a tile-style basemap, colored by daily rides\");\n"}