AkurAI Build
Menu

Public repository / 439f0dcabf5cdf2b120c7724be895cf1df30507f

AkurAI-Framework / site/frontend/ai/components/content/geo_map.js

Latest change 67a966d4abf972b300e8c6c214a30a0279103337 - feat(ai): Artifacts content components + geo map + item carousel (AB3-C) by Ólafur Búi Ólafsson

/**
 * Geo Map — Artifacts / content component.
 * Payload: { lat: number, lng: number, zoom?: number, markers?: Array<{ lat: number, lng: number, label?: string }> }
 *
 * No external map library. Strategy:
 * 1. Always renders a coordinate panel + SVG pin canvas (honest, always works).
 * 2. If the payload supplies a `tileUrl` template string (e.g. a local/self-hosted tile
 *    server with no API key requirement), renders a static tile <img> underneath and
 *    overlays SVG pins proportionally. The tile URL is NOT constructed automatically
 *    because all major tile providers require an API key. Pass it explicitly:
 *      tileUrl: "https://yourserver/{z}/{x}/{y}.png"
 *    where {z}/{x}/{y} follow the OSM XYZ scheme.
 *
 * This keeps the component honest: it never fakes live tiles it cannot provide.
 */

function esc(str) {
  return String(str ?? "")
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

// Convert lat/lng + zoom to OSM tile XY integers
function latLngToTile(lat, lng, zoom) {
  const n = Math.pow(2, zoom);
  const x = Math.floor(((lng + 180) / 360) * n);
  const latRad = (lat * Math.PI) / 180;
  const y = Math.floor(((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n);
  return { x, y, z: zoom };
}

// Mercator: convert lat/lng to fractional tile pixel offset (0–256 per tile)
function latLngToPixelOffset(lat, lng, centerLat, centerLng, zoom, width, height) {
  const n = Math.pow(2, zoom) * 256; // total world pixels at this zoom
  const toPixX = (lo) => ((lo + 180) / 360) * n;
  const toPixY = (la) => {
    const r = (la * Math.PI) / 180;
    return ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * n;
  };
  const cx = toPixX(centerLng);
  const cy = toPixY(centerLat);
  return {
    px: width / 2 + (toPixX(lng) - cx),
    py: height / 2 + (toPixY(lat) - cy),
  };
}

function buildSvgOverlay(markers, lat, lng, zoom, w, h) {
  const allMarkers = [{ lat, lng, label: "" }, ...(markers || [])];
  const pins = allMarkers.map((m, i) => {
    const { px, py } = latLngToPixelOffset(m.lat, m.lng, lat, lng, zoom, w, h);
    const color = i === 0 ? "var(--accent)" : "var(--warn)";
    const labelHtml = m.label
      ? `<text x="${px}" y="${py - 18}" text-anchor="middle" font-size="11" fill="var(--fg)" font-family="ui-sans-serif,system-ui,sans-serif">${esc(m.label)}</text>`
      : "";
    return `
<g transform="translate(${px},${py})">
  ${labelHtml ? `<text x="0" y="-18" text-anchor="middle" font-size="11" fill="var(--fg)" font-family="ui-sans-serif,system-ui,sans-serif">${esc(m.label || "")}</text>` : ""}
  <path d="M0,-16 C-6,-16 -10,-10 -10,-4 C-10,4 0,14 0,14 C0,14 10,4 10,-4 C10,-10 6,-16 0,-16Z" fill="${color}" opacity="0.9"/>
  <circle cx="0" cy="-4" r="3.5" fill="#fff" opacity="0.85"/>
</g>`;
  });
  return `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" xmlns="http://www.w3.org/2000/svg" class="tc-content-geomap__svg-overlay" aria-hidden="true">${pins.join("")}</svg>`;
}

function fmtCoord(n, pos, neg) {
  const abs = Math.abs(n).toFixed(5);
  return `${abs}° ${n >= 0 ? pos : neg}`;
}

export const def = {
  name: "geo_map",
  category: "Artifacts",
  title: "Geo Map",

  match(payload) {
    return (
      payload !== null &&
      typeof payload === "object" &&
      typeof payload.lat === "number" &&
      typeof payload.lng === "number"
    );
  },

  render(payload) {
    const { lat, lng, zoom = 13, markers = [], tileUrl } = payload;

    const W = 560;
    const H = 280;

    const wrap = document.createElement("div");
    wrap.className = "tc-content-geomap";
    wrap.setAttribute("role", "img");
    wrap.setAttribute("aria-label", `Map centred on ${lat.toFixed(4)}, ${lng.toFixed(4)}`);

    let tileHtml = "";
    if (tileUrl && typeof tileUrl === "string") {
      // Build a 3×2 tile grid centred on the target tile (1 tile = 256px)
      const center = latLngToTile(lat, lng, zoom);
      // Each tile is 256px; we want to show approximately W×H centred
      // so we grab a 3-wide by 2-tall grid and clip it.
      const cols = 3, rows = 2;
      const tiles = [];
      for (let dy = -Math.floor(rows / 2); dy <= Math.floor(rows / 2); dy++) {
        for (let dx = -Math.floor(cols / 2); dx <= Math.floor(cols / 2); dx++) {
          const tx = center.x + dx;
          const ty = center.y + dy;
          const url = tileUrl
            .replace("{z}", center.z)
            .replace("{x}", tx)
            .replace("{y}", ty);
          const left = (dx + Math.floor(cols / 2)) * 256;
          const top = (dy + Math.floor(rows / 2)) * 256;
          tiles.push(`<img src="${esc(url)}" alt="" width="256" height="256" loading="lazy" style="position:absolute;left:${left}px;top:${top}px;">`);
        }
      }
      tileHtml = `<div class="tc-content-geomap__tiles" style="position:absolute;inset:0;overflow:hidden;">${tiles.join("")}</div>`;
    } else {
      // SVG placeholder background showing grid lines, no fake tiles
      tileHtml = `
<svg class="tc-content-geomap__bg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
  <rect width="${W}" height="${H}" fill="var(--panel-2)"/>
  <line x1="0" y1="${H / 2}" x2="${W}" y2="${H / 2}" stroke="var(--border-2)" stroke-width="1"/>
  <line x1="${W / 2}" y1="0" x2="${W / 2}" y2="${H}" stroke="var(--border-2)" stroke-width="1"/>
  ${Array.from({ length: 5 }, (_, i) => {
    const x = (W / 6) * (i + 1);
    return `<line x1="${x}" y1="0" x2="${x}" y2="${H}" stroke="var(--border)" stroke-width="0.5" stroke-dasharray="4 4"/>`;
  }).join("")}
  ${Array.from({ length: 3 }, (_, i) => {
    const y = (H / 4) * (i + 1);
    return `<line x1="0" y1="${y}" x2="${W}" y2="${y}" stroke="var(--border)" stroke-width="0.5" stroke-dasharray="4 4"/>`;
  }).join("")}
</svg>`;
    }

    const svgOverlay = buildSvgOverlay(markers, lat, lng, zoom, W, H);

    wrap.innerHTML = `
<div class="tc-content-geomap__canvas" style="position:relative;width:${W}px;height:${H}px;overflow:hidden;border-radius:var(--radius) var(--radius) 0 0;">
  ${tileHtml}
  <div style="position:absolute;inset:0;">${svgOverlay}</div>
</div>
<div class="tc-content-geomap__panel">
  <span class="tc-content-geomap__coord">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="14" height="14" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13S3 17 3 10a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
    ${fmtCoord(lat, "N", "S")}, ${fmtCoord(lng, "E", "W")}
  </span>
  <span class="tc-content-geomap__zoom">zoom ${zoom}</span>
  ${markers.length ? `<span class="tc-content-geomap__markers">${markers.length + 1} pin${markers.length + 1 !== 1 ? "s" : ""}</span>` : ""}
  ${!tileUrl ? `<span class="tc-content-geomap__note">Pass <code>tileUrl</code> for tile overlay</span>` : ""}
</div>
`;
    return wrap;
  },

  example: {
    lat: 65.6835,
    lng: -18.1002,
    zoom: 13,
    markers: [
      { lat: 65.684, lng: -18.098, label: "AkurAI HQ" },
    ],
  },
};