diff --git a/apix-portal/src/main/resources/META-INF/resources/dashboard.js b/apix-portal/src/main/resources/META-INF/resources/dashboard.js
new file mode 100644
index 0000000..95f658a
--- /dev/null
+++ b/apix-portal/src/main/resources/META-INF/resources/dashboard.js
@@ -0,0 +1,100 @@
+(function () {
+ var data = window.__D || {};
+ var visits = data.recentVisits || [];
+ var hasRegistrar = typeof data.registrarLat === 'number' && typeof data.registrarLon === 'number';
+
+ // ── Expires display ──────────────────────────────────────────────────────
+ var expiresEl = document.getElementById('expires-val');
+ var expiresSub = document.getElementById('expires-sub');
+ if (data.expiresAt && expiresEl) {
+ var d = new Date(data.expiresAt);
+ expiresEl.textContent = d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
+ var days = Math.ceil((d - Date.now()) / 86400000);
+ expiresSub.textContent = days > 0 ? days + ' days remaining' : 'expired';
+ }
+
+ // ── Map ──────────────────────────────────────────────────────────────────
+ var wrap = document.getElementById('map-wrap');
+ var svg = d3.select('#world-map');
+ var W = wrap.clientWidth || 800;
+ var H = Math.round(W * 0.48);
+ svg.attr('viewBox', '0 0 ' + W + ' ' + H)
+ .attr('width', W)
+ .attr('height', H);
+
+ var projection = d3.geoNaturalEarth1()
+ .scale(W / 6.3)
+ .translate([W / 2, H / 2]);
+ var path = d3.geoPath(projection);
+
+ // Ocean
+ svg.append('rect')
+ .attr('width', W).attr('height', H)
+ .attr('fill', '#060d18');
+
+ fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json')
+ .then(function(r) { return r.json(); })
+ .then(function(world) {
+ // Land
+ svg.append('path')
+ .datum(topojson.feature(world, world.objects.countries))
+ .attr('d', path)
+ .attr('fill', '#111820')
+ .attr('stroke', '#1e2a38')
+ .attr('stroke-width', 0.5);
+
+ // Graticule (faint grid)
+ svg.append('path')
+ .datum(d3.geoGraticule()())
+ .attr('d', path)
+ .attr('fill', 'none')
+ .attr('stroke', '#0d1a28')
+ .attr('stroke-width', 0.4);
+
+ // Agent blinks — resolve duplicates by approximate cell
+ var cellSize = 1.5; // degrees
+ var cells = {};
+ visits.forEach(function(v) {
+ var cell = Math.round(v.lat / cellSize) + ',' + Math.round(v.lon / cellSize);
+ if (!cells[cell]) cells[cell] = { lat: v.lat, lon: v.lon, count: 0 };
+ cells[cell].count++;
+ });
+ var points = Object.values(cells);
+ points.forEach(function(pt, i) {
+ var pos = projection([pt.lon, pt.lat]);
+ if (!pos) return;
+ var r = Math.min(2 + Math.log1p(pt.count) * 1.5, 8);
+ svg.append('circle')
+ .attr('class', 'agent-blink')
+ .attr('cx', pos[0])
+ .attr('cy', pos[1])
+ .attr('r', r)
+ .style('animation-delay', (i * 190 % 5000) + 'ms');
+ });
+
+ // Registrar star — drawn last so it sits on top
+ if (hasRegistrar) {
+ var pos = projection([data.registrarLon, data.registrarLat]);
+ if (pos) {
+ svg.append('text')
+ .attr('class', 'registrar-star')
+ .attr('x', pos[0])
+ .attr('y', pos[1])
+ .text('★');
+ }
+ }
+ })
+ .catch(function(e) {
+ console.warn('Map load failed:', e);
+ });
+
+ // ── Resize ────────────────────────────────────────────────────────────────
+ window.addEventListener('resize', function() {
+ W = wrap.clientWidth || 800;
+ H = Math.round(W * 0.48);
+ svg.attr('viewBox', '0 0 ' + W + ' ' + H)
+ .attr('width', W).attr('height', H);
+ projection.scale(W / 6.3).translate([W / 2, H / 2]);
+ svg.selectAll('path').attr('d', path);
+ });
+})();
diff --git a/apix-portal/src/main/resources/templates/DashboardResource/dashboard.html b/apix-portal/src/main/resources/templates/DashboardResource/dashboard.html
index ea9b1bb..5bd7932 100644
--- a/apix-portal/src/main/resources/templates/DashboardResource/dashboard.html
+++ b/apix-portal/src/main/resources/templates/DashboardResource/dashboard.html
@@ -186,110 +186,7 @@ var __D = {dataJson.raw};
-{#raw}
-
-{/raw}
+