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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
// 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" },
],
},
};