ext-apps/examples/map-server/test-standalone.html
Olivier Chafik 366aa9b66e
examples: Add CesiumJS Map App example (#235)
* feat: Add CesiumJS map server example

A WebGL-based 3D globe example that demonstrates:
- CesiumJS integration with MCP Apps
- CSP configuration for external tile servers
- worker-src directive usage for tile decoding

Tools:
- geocode: Search places via OpenStreetMap Nominatim
- show-map: Display interactive 3D globe at coordinates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* comment out app.registerTool call for now

* prettier

* fix error handling in server-utils.ts

* refactor: rename cesium-map-server to map-server

- Rename examples/cesium-map-server to examples/map-server
- Update package name to @modelcontextprotocol/server-map
- Add README.md with documentation

* style: add min-height of 400px to map container

* feat(map-server): add fullscreen support, clean UI, sharp rendering

- Add fullscreen toggle button (only shown if host supports it)
- Use autoResize: false and manually send height of 400px
- Hide Cesium UI controls (home, scene mode picker)
- Remove location label (gets stale quickly)
- Use full device pixel ratio for sharp rendering on high-DPI displays
- Resize Cesium viewer when display mode changes

* feat(map-server): add reverse geocoding (logging only) and rounded corners

- Add camera move end listener for reverse geocoding
- Debounce Nominatim API calls (1.5s) to respect rate limits
- Log location name to console on camera move
- Add nominatim.openstreetmap.org to CSP connectDomains
- Add 8px rounded corners to map container

* fix(map-server): fix pixelated rendering on high-DPI displays

- Set canvas imageRendering to 'auto' (CesiumJS defaults to pixelated)
- Set resolutionScale to window.devicePixelRatio for native resolution

* tweaks

* update model context w/ current location

* fix(map-server): improve rendering quality and add visible extent logging

Rendering fixes:
- Remove incorrect resolutionScale = devicePixelRatio which was doubling
  the scaling factor on Retina displays (2x2=4x), causing blurriness
- Disable FXAA anti-aliasing which can cause blurriness on high-DPI displays
- Keep useBrowserRecommendedResolution: false to use native devicePixelRatio

Reverse geocoding improvements:
- Add getVisibleExtent() using camera.computeViewRectangle() to get the
  visible bounding box (west, south, east, north)
- Add getScaleDimensions() to calculate visible area in km
- Add calculateNominatimZoom() to map visible extent to appropriate
  Nominatim zoom level (3=country to 18=building)
- Log visible extent with dimensions on each camera move
- Pass zoom parameter to Nominatim for context-aware geocoding results
- Update model context with visible area dimensions

* fix(map-server): direct positioning + wait for tiles before showing

Changes:
- Remove zoom granularity logic from reverse geocoding (was not working)
- Keep extent logging in both console and model context
- Replace flyTo animation with instant setView positioning
- Add waitForTilesLoaded() to detect when tiles finish loading
- Keep loading spinner visible until:
  1. Tool input positions camera to destination
  2. Tiles at destination are fully loaded
- Add 5-second fallback timeout if no tool input received
- Add 10-second timeout for tile loading to prevent infinite wait

* feat(map-server): use viewbox search to find visible places

Replace center-point reverse geocoding with Nominatim viewbox search:
- Add searchPlacesInBox() that queries Nominatim with bounded viewbox
- Uses layer=address&featuretype=settlement to find cities/towns
- Returns up to 5 visible places in the current view
- Update model context with 'Visible places: City1, City2, ...'
- Still includes center coordinates and extent dimensions

This provides better context about what's actually visible on the map
rather than just the address at the center point.

* fix(map-server): use CARTO @2x retina tiles for sharp rendering

Root cause: Standard OSM tiles are 256x256 pixels. On Retina displays
(devicePixelRatio=2), these get scaled up 2x, making them look pixelated.

Solution: Switch to CARTO Voyager tiles which provide @2x retina support:
- Detect high-DPI displays using window.devicePixelRatio
- Use 512x512 tiles with @2x suffix on Retina displays
- Use 256x256 tiles on standard displays
- Set tileWidth/tileHeight to match actual tile size
- Use CARTO subdomains (a,b,c,d) for load balancing
- Update CSP to allow CARTO tile domains

CARTO Voyager provides clean OSM-based tiles with free @2x retina support,
no API key required.

* fix(map-server): use OSM tiles and fix reverse geocoding

- Switch back from CARTO to standard OSM tiles (tile.openstreetmap.org)
  for better detail and label rendering
- Fix geocoding: use reverse geocode API instead of search API
  (search API requires a q= parameter, returns 400 without it)
- Update CSP domains for OSM tile server

* fix(map-server): use *.openstreetmap.org wildcard in CSP

Simplify CSP to use wildcard for both tiles and geocoding:
- connectDomains: *.openstreetmap.org covers tile.* and nominatim.*
- resourceDomains: *.openstreetmap.org for tile images

* feat(map-server): multi-point sampling for visible places

- Sample multiple points in visible extent to discover places
- Adaptive sampling based on extent size:
  - < 30km: center only (city zoom)
  - 30-100km: center + 2 corners
  - > 100km: center + 4 quadrants
- Proper rate limiting for Nominatim (1.1s between requests)
- Shows 'Visible places: City1, City2, ...' in model context
- Fix: move geocode tool registration inside createServer()

* format

* fix(map-server): fix Express listen callback + add e2e tests

- Fix incorrect Express listen() callback signature (err param doesn't exist)
- Add map-server to e2e test suite with SLOW_SERVERS timeout (5s for tiles)
- Improve comment about experimental ui/update-model-context API

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:13:56 +00:00

134 lines
4.8 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CesiumJS Standalone Test</title>
<link href="https://cesium.com/downloads/cesiumjs/releases/1.123/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
<style>
html, body, #cesiumContainer {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
#status {
position: absolute;
top: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.8);
color: #0f0;
padding: 10px;
font-family: monospace;
font-size: 12px;
z-index: 1000;
max-width: 400px;
max-height: 300px;
overflow-y: auto;
}
</style>
</head>
<body>
<div id="cesiumContainer"></div>
<div id="status">Loading CesiumJS...</div>
<script src="https://cesium.com/downloads/cesiumjs/releases/1.123/Build/Cesium/Cesium.js"></script>
<script>
const status = document.getElementById('status');
function log(msg) {
console.log(msg);
status.innerHTML += '<br>' + msg;
status.scrollTop = status.scrollHeight;
}
try {
log('CesiumJS version: ' + Cesium.VERSION);
// Disable Cesium Ion
Cesium.Ion.defaultAccessToken = undefined;
log('Ion disabled');
// Set default camera view rectangle (required when Ion is disabled)
Cesium.Camera.DEFAULT_VIEW_RECTANGLE = Cesium.Rectangle.fromDegrees(
-130, 20, -60, 55 // USA bounding box
);
log('Default view rectangle set');
// Create viewer with no base layer - expose globally for debugging
const viewer = new Cesium.Viewer('cesiumContainer', {
baseLayer: false,
geocoder: false,
baseLayerPicker: false,
animation: false,
timeline: false,
homeButton: true,
sceneModePicker: true,
navigationHelpButton: false,
fullscreenButton: false,
terrainProvider: undefined,
});
window.viewer = viewer; // Expose for debugging
log('Viewer created');
// Set globe properties
viewer.scene.globe.show = true;
viewer.scene.globe.enableLighting = false;
viewer.scene.globe.baseColor = Cesium.Color.DARKSLATEGRAY;
// Disable request render mode - force continuous rendering
viewer.scene.requestRenderMode = false;
log('Globe configured, continuous rendering enabled');
// Create OSM imagery provider
log('Creating OSM provider...');
const osmProvider = new Cesium.UrlTemplateImageryProvider({
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
minimumLevel: 0,
maximumLevel: 19,
credit: new Cesium.Credit('OpenStreetMap'),
});
// Add error handler
osmProvider.errorEvent.addEventListener((error) => {
log('OSM ERROR: ' + JSON.stringify(error));
});
// Add the imagery layer
const layer = viewer.imageryLayers.addImageryProvider(osmProvider);
log('OSM layer added: ' + (layer ? 'success' : 'failed'));
// Track tile loading
viewer.scene.globe.tileLoadProgressEvent.addEventListener((queueLength) => {
if (queueLength > 0) {
log('Tiles loading: ' + queueLength);
}
});
// Use flyTo with Rectangle - most reliable for initial view
log('Flying to USA rectangle...');
viewer.camera.flyTo({
destination: Cesium.Rectangle.fromDegrees(-130, 20, -60, 55),
duration: 0
});
// Force renders using setInterval (bypasses rAF throttling)
let renderCount = 0;
const intervalId = setInterval(() => {
viewer.render();
renderCount++;
if (renderCount <= 3) {
log('Interval render #' + renderCount);
}
if (renderCount > 20) {
clearInterval(intervalId);
log('Stopped interval rendering');
}
}, 50);
log('FlyTo called, interval rendering started');
log('Initialization complete!');
} catch (e) {
log('ERROR: ' + e.message);
console.error(e);
}
</script>
</body>
</html>