{"spec_id":"map-tile-background","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// map-tile-background: Map with Tile Background\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 84/100 | Created: 2026-08-26\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// European landmarks with annual visitor counts (millions). Longitude/latitude\n// double as the chart's x/y coordinates (a simple equirectangular projection),\n// so Chart.js's own linear scales and gridlines become the map's graticule.\nconst landmarks = [\n  { name: \"Eiffel Tower\", city: \"Paris\", lat: 48.858, lon: 2.294, visitorsM: 6.9 },\n  { name: \"British Museum\", city: \"London\", lat: 51.519, lon: -0.127, visitorsM: 5.8 },\n  { name: \"Colosseum\", city: \"Rome\", lat: 41.89, lon: 12.492, visitorsM: 7.6 },\n  { name: \"Sagrada Familia\", city: \"Barcelona\", lat: 41.404, lon: 2.174, visitorsM: 4.7 },\n  { name: \"Anne Frank House\", city: \"Amsterdam\", lat: 52.375, lon: 4.884, visitorsM: 1.2 },\n  { name: \"Brandenburg Gate\", city: \"Berlin\", lat: 52.516, lon: 13.378, visitorsM: 3.8 },\n  { name: \"Acropolis\", city: \"Athens\", lat: 37.971, lon: 23.726, visitorsM: 3.5 },\n  { name: \"Neuschwanstein Castle\", city: \"Bavaria\", lat: 47.557, lon: 10.75, visitorsM: 1.5 },\n  { name: \"Prado Museum\", city: \"Madrid\", lat: 40.414, lon: -3.692, visitorsM: 3.2 },\n  { name: \"Charles Bridge\", city: \"Prague\", lat: 50.086, lon: 14.411, visitorsM: 4.1 },\n  { name: \"Edinburgh Castle\", city: \"Edinburgh\", lat: 55.949, lon: -3.2, visitorsM: 2.2 },\n  { name: \"Alhambra\", city: \"Granada\", lat: 37.176, lon: -3.588, visitorsM: 2.7 },\n];\nconst visitorValues = landmarks.map((l) => l.visitorsM);\nconst minVisitors = Math.min(...visitorValues);\nconst maxVisitors = Math.max(...visitorValues);\nconst radiusFor = (v) => {\n  const ratio = (v - minVisitors) / (maxVisitors - minVisitors);\n  return 10 + ratio * 22; // few points -> prominent bubbles\n};\n\n// Simplified Western/Central Europe coastline (lon/lat) — stylized geographic\n// context only, not survey-accurate. Used purely to pick \"land\" vs \"sea\" tile\n// shading for the basemap; not itself a data series.\nconst EUROPE_COASTLINE = [\n  [-10, 44], [-9, 38], [-6, 36.5], [-1, 36.8], [3, 39], [3, 41.5],\n  [7, 43.5], [10, 44], [13, 42], [16, 40], [19, 40], [22, 37],\n  [24, 37.5], [24, 40], [21, 39.5], [23, 41.5], [26, 42], [28, 41],\n  [29, 41.5], [28, 45], [30, 46], [28, 46.5], [30, 49], [24, 49],\n  [23, 52], [19, 54.5], [14, 54.5], [8, 53.5], [4, 51.4], [1.5, 50.9],\n  [-2, 49.5], [-4.5, 48.5], [-2.5, 47.2], [-4.5, 47.9], [-1, 46.2],\n  [-1.7, 43.5], [-9, 43],\n];\nconst BRITISH_ISLES = [\n  [-5.5, 50], [-3, 50.5], [1.3, 51.9], [0.5, 53.5], [-2, 55.8],\n  [-3, 58.6], [-5.2, 58.5], [-6, 56], [-5, 53.4], [-6.5, 55.3],\n  [-8.5, 54.5], [-10, 52], [-8, 51.5], [-5.5, 50],\n];\n\nconst pointInPolygon = (lon, lat, polygon) => {\n  let inside = false;\n  for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {\n    const [xi, yi] = polygon[i];\n    const [xj, yj] = polygon[j];\n    const intersects = yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;\n    if (intersects) inside = !inside;\n  }\n  return inside;\n};\nconst isLand = (lon, lat) =>\n  pointInPolygon(lon, lat, EUROPE_COASTLINE) || pointInPolygon(lon, lat, BRITISH_ISLES);\n\n// --- Custom draw plugins -----------------------------------------------------\n// Chart.js has no native tile/raster basemap layer, and the offline render\n// harness cannot fetch real OpenStreetMap/CartoDB/satellite tiles. This plugin\n// draws a stylized tile grid directly with the canvas context, projected\n// through the chart's own linear scales — land/sea tiles are shaded\n// differently and a visible tile seam grid stands in for a raster slippy-map\n// tileset, giving the \"tile background\" concept honestly without pretending\n// to be live provider imagery.\nconst TILE_COLUMNS = 14;\nconst tileBasemapPlugin = {\n  id: \"tileBasemap\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, chartArea, scales } = chart;\n    const tileSize = chartArea.width / TILE_COLUMNS;\n    const rows = Math.ceil(chartArea.height / tileSize);\n    // Tile shading kept close in tone (checker vs. alt) and seams drawn at low\n    // alpha so the tile grid reads as a subtle backdrop, not a hard checkerboard.\n    const landFill = window.ANYPLOT_THEME === \"light\" ? \"rgba(107, 106, 99, 0.14)\" : \"rgba(168, 167, 159, 0.14)\";\n    const landFillAlt = window.ANYPLOT_THEME === \"light\" ? \"rgba(107, 106, 99, 0.17)\" : \"rgba(168, 167, 159, 0.17)\";\n    const seaFill = window.ANYPLOT_THEME === \"light\" ? \"rgba(68, 103, 163, 0.07)\" : \"rgba(68, 103, 163, 0.10)\";\n    const seaFillAlt = window.ANYPLOT_THEME === \"light\" ? \"rgba(68, 103, 163, 0.09)\" : \"rgba(68, 103, 163, 0.13)\";\n    const tileSeam = window.ANYPLOT_THEME === \"light\" ? \"rgba(26, 26, 23, 0.05)\" : \"rgba(250, 248, 241, 0.05)\";\n    const coastlineStroke = window.ANYPLOT_THEME === \"light\" ? \"rgba(26, 26, 23, 0.28)\" : \"rgba(250, 248, 241, 0.28)\";\n\n    ctx.save();\n    ctx.strokeStyle = tileSeam;\n    ctx.lineWidth = 1;\n    for (let row = 0; row < rows; row += 1) {\n      for (let col = 0; col < TILE_COLUMNS; col += 1) {\n        const x = chartArea.left + col * tileSize;\n        const y = chartArea.top + row * tileSize;\n        const w = Math.min(tileSize, chartArea.right - x);\n        const h = Math.min(tileSize, chartArea.bottom - y);\n        if (w <= 0 || h <= 0) continue;\n        const centerLon = scales.x.getValueForPixel(x + w / 2);\n        const centerLat = scales.y.getValueForPixel(y + h / 2);\n        const checker = (row + col) % 2 === 0;\n        const land = isLand(centerLon, centerLat);\n        ctx.fillStyle = land ? (checker ? landFillAlt : landFill) : checker ? seaFillAlt : seaFill;\n        ctx.fillRect(x, y, w, h);\n        ctx.strokeRect(x, y, w, h);\n      }\n    }\n\n    // A crisp coastline stroke on top of the soft tiles gives the map real\n    // geographic definition without relying on the checker pattern alone.\n    ctx.strokeStyle = coastlineStroke;\n    ctx.lineWidth = 1.5;\n    [EUROPE_COASTLINE, BRITISH_ISLES].forEach((polygon) => {\n      ctx.beginPath();\n      polygon.forEach(([lon, lat], idx) => {\n        const px = scales.x.getPixelForValue(lon);\n        const py = scales.y.getPixelForValue(lat);\n        if (idx === 0) ctx.moveTo(px, py);\n        else ctx.lineTo(px, py);\n      });\n      ctx.closePath();\n      ctx.stroke();\n    });\n    ctx.restore();\n  },\n};\n\n// Small caption naming the basemap style honestly (illustrative tile grid,\n// not a live tile-provider fetch), sitting in the empty Atlantic corner.\nconst basemapCaptionPlugin = {\n  id: \"basemapCaption\",\n  afterDraw(chart) {\n    const { ctx, chartArea } = chart;\n    ctx.save();\n    ctx.font = \"14px sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"bottom\";\n    ctx.fillText(\"Basemap: stylized tile grid (offline render, no live tile provider)\", chartArea.left + 4, chartArea.bottom - 4);\n    ctx.restore();\n  },\n};\n\nconst labelPlugin = {\n  id: \"landmarkLabels\",\n  afterDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    ctx.save();\n    ctx.font = \"13px sans-serif\";\n    ctx.fillStyle = t.ink;\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"middle\";\n    landmarks.forEach((l) => {\n      const x = scales.x.getPixelForValue(l.lon);\n      const y = scales.y.getPixelForValue(l.lat);\n      ctx.fillText(l.name, x + radiusFor(l.visitorsM) + 6, y);\n    });\n    ctx.restore();\n  },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Title (scale fontsize to the rendered length, see plot-generator.md) ---\nconst title = \"European Landmark Visitors · map-tile-background · javascript · chartjs · anyplot.ai\";\nconst titleFontSize = Math.max(15, Math.round(22 * Math.min(1, 67 / title.length)));\n\n// --- Chart -------------------------------------------------------------------\nconst chart = new Chart(canvas, {\n  type: \"bubble\",\n  data: {\n    datasets: [\n      {\n        label: \"Landmarks\",\n        data: landmarks.map((l) => ({ x: l.lon, y: l.lat, r: radiusFor(l.visitorsM) })),\n        backgroundColor: t.palette[0],\n        borderColor: t.pageBg,\n        borderWidth: 1.5,\n      },\n    ],\n  },\n  plugins: [tileBasemapPlugin, basemapCaptionPlugin, labelPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 16, right: 120, bottom: 16, left: 16 } },\n    plugins: {\n      title: {\n        display: true,\n        text: title,\n        color: t.ink,\n        font: { size: titleFontSize, weight: \"500\" },\n      },\n      legend: { display: false },\n      tooltip: {\n        callbacks: {\n          label: (ctx) => {\n            const l = landmarks[ctx.dataIndex];\n            return `${l.name}, ${l.city}: ${l.visitorsM}M visitors/year`;\n          },\n        },\n      },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: -12,\n        max: 32,\n        title: { display: true, text: \"Longitude\", color: t.ink, font: { size: 16 } },\n        ticks: { color: t.inkSoft, font: { size: 14 }, stepSize: 4, callback: (v) => `${v}°` },\n        grid: { color: t.grid },\n      },\n      y: {\n        type: \"linear\",\n        min: 35,\n        max: 58,\n        title: { display: true, text: \"Latitude\", color: t.ink, font: { size: 16 } },\n        ticks: { color: t.inkSoft, font: { size: 14 }, stepSize: 4, callback: (v) => `${v}°` },\n        grid: { color: t.grid },\n      },\n    },\n  },\n});\n\n// --- Zoom & pan (native wheel/drag, no external plugin) ---------------------\n// The spec asks interactive libraries to let users explore the map at\n// different scales; chartjs-plugin-zoom isn't installed in this runtime, so\n// wheel-to-zoom and drag-to-pan are wired directly onto the linear scales.\ncanvas.style.cursor = \"grab\";\nlet isPanning = false;\nlet lastX = 0;\nlet lastY = 0;\ncanvas.addEventListener(\"mousedown\", (evt) => {\n  isPanning = true;\n  lastX = evt.offsetX;\n  lastY = evt.offsetY;\n  canvas.style.cursor = \"grabbing\";\n});\ncanvas.addEventListener(\"mousemove\", (evt) => {\n  if (!isPanning) return;\n  const { x: xScale, y: yScale } = chart.scales;\n  const dLon = xScale.getValueForPixel(lastX) - xScale.getValueForPixel(evt.offsetX);\n  const dLat = yScale.getValueForPixel(lastY) - yScale.getValueForPixel(evt.offsetY);\n  xScale.options.min += dLon;\n  xScale.options.max += dLon;\n  yScale.options.min += dLat;\n  yScale.options.max += dLat;\n  lastX = evt.offsetX;\n  lastY = evt.offsetY;\n  chart.update(\"none\");\n});\n[\"mouseup\", \"mouseleave\"].forEach((evtName) =>\n  canvas.addEventListener(evtName, () => {\n    isPanning = false;\n    canvas.style.cursor = \"grab\";\n  }),\n);\ncanvas.addEventListener(\n  \"wheel\",\n  (evt) => {\n    evt.preventDefault();\n    const { x: xScale, y: yScale } = chart.scales;\n    const zoomFactor = evt.deltaY < 0 ? 0.9 : 1.1;\n    const cursorLon = xScale.getValueForPixel(evt.offsetX);\n    const cursorLat = yScale.getValueForPixel(evt.offsetY);\n    xScale.options.min = cursorLon + (xScale.min - cursorLon) * zoomFactor;\n    xScale.options.max = cursorLon + (xScale.max - cursorLon) * zoomFactor;\n    yScale.options.min = cursorLat + (yScale.min - cursorLat) * zoomFactor;\n    yScale.options.max = cursorLat + (yScale.max - cursorLat) * zoomFactor;\n    chart.update(\"none\");\n  },\n  { passive: false },\n);\n"}