This plan documents the technical implementation of MMGIS's comprehensive tool plugin architecture. The system provides 15 user-facing interactive tools through a unified plugin interface, enabling consistent integration while allowing domain-specific functionality.
Core Module: src/essence/Basics/ToolController_/ToolController_.js
Responsibilities:
- Tool registration and lifecycle management
- Dynamic loading via
toolModulesexports - UI positioning (toolbar vs separated tools)
- State coordination and mutual exclusion
- URL state persistence
Key Functions:
activateTool(toolName)- Initialize and display tooldeactivateTool(toolName)- Clean up and hide toolgetToolState()- Retrieve tool configurationsetToolState(state)- Restore tool from URL
All tools implement standardized interface:
{
height: Number, // Panel height (0 = variable)
width: Number, // Panel width in pixels
MMGISInterface: Object, // Core interface object
make: function(), // Initialize tool
destroy: function(), // Clean up resources
getUrlString: function() // Serialize state to URL
}Location: configure/public/toolConfigs.json
Structure per tool:
{
"name": "Tool Name",
"description": "Tool description",
"icon": "mdi-icon-name",
"toolbarPriority": 1000,
"expandable": true,
"separatedTool": false,
"variables": {
"configOption": "default value"
},
"uiConfig": [
{
"type": "text",
"field": "configOption",
"label": "Configuration Option"
}
]
}Module: src/essence/Tools/Kinds/Kinds.js
Purpose: Configuration plugin defining layer click behaviors
Behavior Types:
info- Open Info Tool with feature propertieswaypoint- Display rover/vehicle overlayschemistry_tool- Activate Chemistry Tooldraw_tool- Enable Draw Tool editing
Implementation: Maps layer configuration to tool activation patterns
Location: src/essence/Tools/Animation/AnimationTool.js
Technology Stack:
- HTML2Canvas for map canvas capture
- gifshot for GIF encoding
- @ffmpeg/ffmpeg (WebAssembly) for MP4 encoding
- JSZip for PNG sequence packaging
Key Functions:
function captureFrame(bbox, time) {
// Hide UI elements
hideUIForCapture();
// Zoom to bbox
Map_.fitBounds(bbox);
// Update time-enabled layers
TimeControl.setTime(time);
await waitForLayerRender();
// Capture with HTML2Canvas
const canvas = await html2canvas(mapElement);
return canvas.toDataURL();
}
function exportAnimation(frames, format, fps) {
if (format === 'gif') {
return gifshot.createGIF({
images: frames,
gifWidth: width,
gifHeight: height,
interval: 1000 / fps
});
} else if (format === 'mp4') {
// FFmpeg WebAssembly encoding
return ffmpeg.encode(frames, fps);
} else if (format === 'png') {
// ZIP packaging
const zip = new JSZip();
frames.forEach((frame, i) => {
zip.file(`frame_${i}.png`, frame, {base64: true});
});
return zip.generateAsync({type: 'blob'});
}
}Performance Considerations:
- Large animations (100+ frames) consume significant memory
- MP4 encoding is CPU-intensive (uses Web Workers)
- Recommended max: 1920x1080 resolution, 10 second duration
Location: src/essence/Tools/Chemistry/ChemistryTool.js
Technology Stack:
- D3.js for chart rendering
- Custom
chemistrychart.jsandchemistryplot.jslibraries
Chart Types:
- Bar chart (element percentages)
- Pie chart (proportional distribution)
- Scatter plot (multi-point comparison)
Data Sources:
- Feature properties with chemistry keys (SiO2, Fe2O3, etc.)
- Linked CSV datasets via
properties.datasets - Remote chemistry endpoints
Expected Feature Properties:
{
"name": "Sample Site A",
"SiO2": 45.2,
"Fe2O3": 15.8,
"Al2O3": 10.3,
"MgO": 8.5
// Additional oxide percentages...
}Location: src/essence/Tools/Curtain/CurtainTool.js
Technology Stack:
- OpenSeadragon for high-resolution radargram viewing
- Cesium for 3D curtain rendering
- Custom shaders for vertical positioning
Data Format:
- Georeferenced radar imagery with depth metadata
- Sol number and Rover Motion Counter (RMC) metadata
Layer Configuration Example:
{
"name": "GPR Data Sol 015",
"type": "vector",
"kind": "curtain",
"curtainImagery": "path/to/radargram.png",
"curtainDepth": 10,
"curtainMetadata": {
"sol": 15,
"rmc_start": 1000,
"rmc_end": 1500
}
}Location: src/essence/Tools/Draw/DrawTool.js
Component Structure:
DrawTool.js // Main controller
├── DrawTool_Drawing.js // Drawing UI and Leaflet.draw integration
├── DrawTool_Editing.js // Feature editing and properties
├── DrawTool_Files.js // File list and operations
├── DrawTool_History.js // History timeline and undo
├── DrawTool_Shapes.js // Feature list and selection
├── DrawTool_Publish.js // Publishing and master files
├── DrawTool_FileModal.js // File creation/upload modal
└── DrawTool_Templater.js // Template designer
Database Schema:
user_files:
CREATE TABLE user_files (
id SERIAL PRIMARY KEY,
file_owner VARCHAR(50) NOT NULL,
file_owner_group STRING[],
file_name VARCHAR(355) NOT NULL,
file_description VARCHAR(10000),
is_master BOOLEAN DEFAULT false,
intent ENUM('roi','campaign','campsite','trail','signpost','all'),
public ENUM('0','1') DEFAULT '0',
hidden ENUM('0','1') DEFAULT '0',
template JSON,
publicity_type VARCHAR(255),
public_editors TEXT[],
created_on TIMESTAMP NOT NULL,
updated_on TIMESTAMP NOT NULL
);user_features:
CREATE TABLE user_features (
id SERIAL PRIMARY KEY,
file_id INTEGER REFERENCES user_files(id),
level INTEGER,
intent ENUM('roi','campaign','campsite','trail','signpost','polygon','line','point','text','arrow'),
properties JSON,
geom GEOMETRY(GEOMETRY,4326),
extant_start BIGINT,
extant_end BIGINT,
trimmed_start BIGINT[],
trimmed_at STRING[],
trimmed_at_final BIGINT
);file_histories:
CREATE TABLE file_histories (
id SERIAL PRIMARY KEY,
file_id INTEGER REFERENCES user_files(id),
history_id INTEGER,
time BIGINT,
action_index INTEGER, -- 0=add, 1=edit, 2=delete, 3=undo, 5=clip-over, 6=merge, 7=clip-under, 8=split
history INT[],
author VARCHAR(255)
);API Endpoints:
Drawing Operations (/api/draw):
// Add feature
POST /api/draw/add
Body: { file_id, geometry, properties, intent, clip: 'over'|'under'|null }
Returns: { status, message, body: { id, intent } }
// Edit feature
POST /api/draw/edit
Body: { file_id, feature_id, geometry, properties, intent }
Returns: { status, message, body: { id, uuid, intent } }
// Remove feature (soft delete)
POST /api/draw/remove
Body: { file_id, id }
Returns: { status, message }
// Undo to timestamp
POST /api/draw/undo
Body: { file_id, undo_time }
Returns: { status, message }
// Merge features
POST /api/draw/merge
Body: { file_id, prop_id, ids: [id1, id2, ...] }
Returns: { status, message, body: { ids: [new_ids] } }
// Split features
POST /api/draw/split
Body: { file_id, split: line_geojson, ids: [id1, id2, ...] }
Returns: { status, message, body: { ids: [new_ids] } }File Management (/api/files):
POST /api/files/get // Retrieve user's files list
POST /api/files/getfile // Retrieve specific file with features
POST /api/files/make // Create new file
POST /api/files/change // Update file metadata
POST /api/files/remove // Delete file and features
POST /api/files/gethistory // Retrieve file edit history
POST /api/files/modifykeyword // Rename/remove folder/tagGeometric Operations:
Clipping (PostGIS ST_DIFFERENCE):
async function clipFeatures(newGeometry, existingFeatures, clipMode) {
if (clipMode === 'over') {
// New geometry clips existing
for (const feature of existingFeatures) {
const clipped = await db.query(
`SELECT ST_AsGeoJSON(ST_Difference(
ST_GeomFromGeoJSON($1),
ST_Buffer(ST_GeomFromGeoJSON($2), 0)
))`,
[feature.geometry, newGeometry]
);
await updateFeature(feature.id, clipped);
}
} else if (clipMode === 'under') {
// New geometry is clipped by existing
let resultGeometry = newGeometry;
for (const feature of existingFeatures) {
resultGeometry = await db.query(
`SELECT ST_AsGeoJSON(ST_Difference(
ST_GeomFromGeoJSON($1),
ST_Buffer(ST_GeomFromGeoJSON($2), 0)
))`,
[resultGeometry, feature.geometry]
);
}
return resultGeometry;
}
}Merge (PostGIS ST_UNION):
async function mergeFeatures(fileId, featureIds, propFeatureId) {
const merged = await db.query(
`SELECT ST_AsGeoJSON(ST_Union(
ST_Buffer(geom, 0.000001, 'endcap=mitre join=mitre')
)) as geometry
FROM user_features
WHERE file_id = $1 AND id = ANY($2)`,
[fileId, featureIds]
);
const properties = await getFeatureProperties(propFeatureId);
return createFeature(fileId, merged.geometry, properties);
}Split (PostGIS ST_SPLIT):
async function splitFeatures(fileId, featureIds, splitLine) {
const results = [];
for (const featureId of featureIds) {
const split = await db.query(
`SELECT ST_AsGeoJSON(
(ST_Dump(ST_Split(geom, ST_GeomFromGeoJSON($1)))).geom
) as geometry
FROM user_features
WHERE id = $2`,
[splitLine, featureId]
);
const properties = await getFeatureProperties(featureId);
for (const geom of split.rows) {
results.push(await createFeature(fileId, geom.geometry, properties));
}
await softDeleteFeature(featureId);
}
return results;
}Template System:
Template Structure:
{
"fields": [
{
"name": "site_name",
"type": "text",
"required": true,
"default": ""
},
{
"name": "sol",
"type": "incrementer",
"prefix": "SOL",
"suffix": "",
"length": 3
},
{
"name": "observation_type",
"type": "dropdown",
"options": ["Image", "Spectra", "ChemCam", "Sample"]
},
{
"name": "observation_date",
"type": "date",
"format": "YYYY-MM-DD"
}
]
}Incrementer Logic:
function getNextIncrementerValue(fileId, field) {
// Get all feature properties for this file
const features = getFileFeatures(fileId);
// Extract used values
const usedValues = features
.map(f => f.properties[field.name])
.filter(v => v && v.match(new RegExp(`^${field.prefix}\\d+${field.suffix}$`)))
.map(v => parseInt(v.replace(field.prefix, '').replace(field.suffix, '')));
// Find next available
let next = 1;
while (usedValues.includes(next)) {
next++;
}
// Format with padding
return `${field.prefix}${next.toString().padStart(field.length, '0')}${field.suffix}`;
}Leaflet Integration:
// Drawing with clip-over/under
L.Draw.Polygon.prototype.addHooks = function() {
// Original Leaflet.draw logic
// ...
// Custom clipping on finish
this._finishShape = async function() {
const drawnGeometry = this._poly.toGeoJSON();
if (DrawTool.clipMode === 'over' || DrawTool.clipMode === 'under') {
const existingFeatures = getIntersectingFeatures(drawnGeometry);
drawnGeometry = await clipFeatures(drawnGeometry, existingFeatures, DrawTool.clipMode);
}
DrawTool.addFeature(drawnGeometry);
};
};Cesium Integration:
Globe_.litho.addLayer('clamped', {
name: 'camptool_DrawTool_' + fileId,
on: true,
geojson: normalizedFeatures,
opacity: 1,
style: { letPropertiesStyleOverride: true }
});Location: src/essence/Tools/Identifier/IdentifierTool.js
Technology Stack:
- GDAL backend for raster queries (
/api/gdal/raster) - Debounced mousemove events
- LRU cache for recent queries
Query Workflow:
async function queryRasterValue(lat, lon, layerConfig) {
// Check cache
const cacheKey = `${lat},${lon},${layerConfig.url}`;
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
}
// Format URL with time if time-enabled
let url = layerConfig.url;
if (layerConfig.time) {
const formattedTime = formatTime(TimeControl.currentTime, layerConfig.timeFormat);
url = url.replace('{time}', formattedTime);
}
// Query GDAL backend
const response = await fetch(`/api/gdal/raster`, {
method: 'POST',
body: JSON.stringify({
url: url,
lat: lat,
lon: lon,
bands: layerConfig.bands
})
});
const data = await response.json();
// Apply scale factor
const values = data.values.map(v => v * (layerConfig.scalefactor || 1));
// Format with sigfigs
const formatted = values.map(v => v.toFixed(layerConfig.sigfigs || 2));
// Cache result
cache.set(cacheKey, formatted);
return formatted;
}Configuration Per Layer:
{
"identifier": {
"layer-uuid-1": {
"url": "path/to/raster.tif",
"bands": 3,
"sigfigs": 2,
"unit": "meters",
"scalefactor": 1.0,
"timeFormat": "%Y-%m-%dT%H:%M:%SZ"
}
}
}Location: src/essence/Tools/Info/InfoTool.js
Key Functions:
function displayFeatureProperties(feature) {
// Sort if configured
let properties = feature.properties;
if (config.sortAlphabetically) {
properties = sortObjectKeys(properties);
}
// Format as JSON with syntax highlighting
const formattedJSON = syntaxHighlight(JSON.stringify(properties, null, 2));
// Render in panel
$('#infoToolContent').html(formattedJSON);
// Add action buttons
addCopyButton(feature);
addLocateButton(feature);
addToggleVisibilityButton(feature);
}
function syntaxHighlight(json) {
return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
function (match) {
let cls = 'number';
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = 'key';
} else {
cls = 'string';
}
} else if (/true|false/.test(match)) {
cls = 'boolean';
} else if (/null/.test(match)) {
cls = 'null';
}
return '<span class="' + cls + '">' + match + '</span>';
}
);
}Multi-Feature Handling:
Map_.on('click', function(e) {
const features = Map_.queryRenderedFeatures(e.point);
if (features.length === 0) {
return;
} else if (features.length === 1) {
InfoTool.displayFeatureProperties(features[0]);
} else {
// Show dropdown for selection
showFeatureSelector(features, function(selectedFeature) {
InfoTool.displayFeatureProperties(selectedFeature);
});
}
});Location: src/essence/Tools/Isochrone/IsochroneTool.js
Key Files:
IsochroneTool_Manager.js- Manages multiple isochrone instancesIsochroneTool_Query.js- Data fetching from tile sourcesIsochroneTool_Algorithm.js- Cost computation algorithms
Algorithm (Dijkstra's on tile grid):
function computeIsochrone(startPoint, costBudget, tileSources) {
// Build graph from tiles
const graph = buildGraphFromTiles(startPoint, tileSources);
// Dijkstra's algorithm
const distances = new Map();
const priorityQueue = new PriorityQueue();
distances.set(startPoint, 0);
priorityQueue.enqueue(startPoint, 0);
while (!priorityQueue.isEmpty()) {
const current = priorityQueue.dequeue();
const currentCost = distances.get(current);
if (currentCost > costBudget) continue;
// Check neighbors
for (const neighbor of getNeighbors(current, graph)) {
const edgeCost = calculateEdgeCost(current, neighbor, tileSources);
const newCost = currentCost + edgeCost;
if (!distances.has(neighbor) || newCost < distances.get(neighbor)) {
distances.set(neighbor, newCost);
priorityQueue.enqueue(neighbor, newCost);
}
}
}
// Generate contour polygons
return generateContours(distances, costBudget);
}
function calculateEdgeCost(from, to, tileSources) {
let cost = 0;
// Distance component
cost += distance(from, to);
// Slope component
if (tileSources.slope) {
const slope = getSlopeValue(to, tileSources.slope);
cost *= (1 + slope / 45); // Scale by slope angle
}
// Obstacle component
if (tileSources.obstacle) {
const obstacle = getObstacleValue(to, tileSources.obstacle);
if (obstacle > 0.8) cost = Infinity; // Impassable
}
// Custom cost layer
if (tileSources.cost) {
const customCost = getCostValue(to, tileSources.cost);
cost *= customCost;
}
return cost;
}Least-Cost Path (on hover):
function computeLeastCostPath(startPoint, hoverPoint, distances) {
const path = [hoverPoint];
let current = hoverPoint;
while (current !== startPoint) {
const neighbors = getNeighbors(current);
// Find neighbor with lowest distance
let minNeighbor = null;
let minDistance = Infinity;
for (const neighbor of neighbors) {
if (distances.has(neighbor) && distances.get(neighbor) < minDistance) {
minDistance = distances.get(neighbor);
minNeighbor = neighbor;
}
}
if (!minNeighbor) break; // No path found
path.unshift(minNeighbor);
current = minNeighbor;
}
return path;
}Location: src/essence/Tools/Layers/LayersTool.js
Key Functions:
function buildLayerTree(layers) {
const tree = $('<ul class="layer-tree"></ul>');
layers.forEach(layer => {
const item = $('<li></li>');
// Toggle checkbox
const checkbox = $('<input type="checkbox">')
.prop('checked', layer.on)
.on('change', () => L_.toggleLayer(layer.uuid));
// Opacity slider
const opacitySlider = $('<input type="range" min="0" max="100">')
.val(layer.opacity * 100)
.on('input', (e) => L_.setLayerOpacity(layer.uuid, e.target.value / 100));
// Info button
const infoButton = $('<button class="mdi mdi-information"></button>')
.on('click', () => showLayerInfo(layer));
// Download button (vector layers only)
if (layer.type === 'vector') {
const downloadButton = $('<button class="mdi mdi-download"></button>')
.on('click', () => downloadLayer(layer));
item.append(downloadButton);
}
item.append(checkbox, layer.name, opacitySlider, infoButton);
// Sublayers
if (layer.sublayers) {
item.append(buildLayerTree(layer.sublayers));
}
tree.append(item);
});
return tree;
}Feature Filtering:
function applyFeatureFilter(layer, filterExpression) {
// Parse filter expression (SQL-like syntax)
const filter = parseFilterExpression(filterExpression);
// Filter features
const filteredFeatures = layer.features.filter(feature => {
return evaluateFilter(feature.properties, filter);
});
// Update layer display
L_.updateLayerFeatures(layer.uuid, filteredFeatures);
}
function parseFilterExpression(expression) {
// Example: "temperature > 300 AND sample_type IN ('soil', 'rock')"
// Returns: { conditions: [...], logic: 'AND' }
// ... parsing logic
}Data Shader Controls:
function updateDataShader(layer, config) {
// Update colormap
if (config.colormap) {
layer.colormap = config.colormap;
}
// Update value range
if (config.min !== undefined || config.max !== undefined) {
layer.range = {
type: 'fixed',
min: config.min,
max: config.max
};
}
// Trigger re-render with new shader config
L_.updateDataLayer(layer.uuid, {
colormap: layer.colormap,
range: layer.range
});
}Location: src/essence/Tools/Legend/LegendTool.js
Legend Sources (priority order):
- CSV file (
layer.legendproperty) - JSON array (
layer.Raw Variables.legend) - Auto-generated from layer styling
- COG colormap from TiTiler metadata
Legend Rendering:
function renderLegend(layer) {
const legendContainer = $('<div class="legend-item"></div>');
// Header
legendContainer.append(`<h3>${layer.name}</h3>`);
if (layer.legend) {
// Discrete legend
layer.legend.forEach(item => {
const row = $(`
<div class="legend-row">
<div class="legend-swatch" style="background-color: ${item.color}"></div>
<div class="legend-label">${item.label}</div>
</div>
`);
legendContainer.append(row);
});
} else if (layer.colorRamp) {
// Gradient legend
const gradient = buildGradient(layer.colorRamp);
const gradientBar = $(`
<div class="legend-gradient" style="background: ${gradient}"></div>
`);
legendContainer.append(gradientBar);
// Min/max labels
legendContainer.append(`
<div class="legend-range">
<span>${layer.range.min} ${layer.units}</span>
<span>${layer.range.max} ${layer.units}</span>
</div>
`);
}
$('#legendToolContent').append(legendContainer);
}
function buildGradient(colorRamp) {
const stops = colorRamp.map((stop, i) => {
const percent = (i / (colorRamp.length - 1)) * 100;
return `${stop.color} ${percent}%`;
}).join(', ');
return `linear-gradient(to right, ${stops})`;
}CSV Legend Format:
value,color,label
0,#0000ff,Ice
1,#00ff00,Vegetation
2,#ffff00,Sand
3,#ff0000,RockJSON Legend Format:
{
"legend": [
{"value": 0, "color": "#0000ff", "label": "Ice"},
{"value": 1, "color": "#00ff00", "label": "Vegetation"},
{"value": 2, "color": "#ffff00", "label": "Sand"},
{"value": 3, "color": "#ff0000", "label": "Rock"}
]
}Location: src/essence/Tools/Measure/MeasureTool.js
Key Files:
MeasureTool.js- Main controllerMeasureComponents.js- React profile chart components
Technology Stack:
- React for UI components
- Chart.js for elevation profiles
- GDAL backend for DEM queries (
/api/gdal/raster)
Measurement Modes:
const MODES = {
SEGMENT: 'segment', // Individual segments
CONTINUOUS: 'continuous', // Cumulative distance
CONTINUOUS_COLOR: 'continuous_color' // Colored by elevation
};Elevation Profile Generation:
async function generateElevationProfile(points, demUrl) {
const profile = [];
let cumulativeDistance = 0;
for (let i = 0; i < points.length; i++) {
const point = points[i];
// Query elevation from DEM
const elevation = await queryDEM(point.lat, point.lng, demUrl);
// Calculate distance from previous point
if (i > 0) {
const prevPoint = points[i - 1];
const segmentDistance = haversineDistance(prevPoint, point);
cumulativeDistance += segmentDistance;
}
profile.push({
lat: point.lat,
lng: point.lng,
elevation: elevation,
distance: cumulativeDistance,
elevationGain: i > 0 ? elevation - profile[i-1].elevation : 0
});
}
return profile;
}
async function queryDEM(lat, lng, demUrl) {
const response = await fetch('/api/gdal/raster', {
method: 'POST',
body: JSON.stringify({ url: demUrl, lat, lng, bands: 1 })
});
const data = await response.json();
return data.values[0];
}Line-of-Sight Analysis:
function calculateLineOfSight(observer, target, profile, observerHeight, targetHeight) {
const los = {
visible: true,
obstructions: []
};
// Observer and target adjusted elevations
const obsElev = profile[0].elevation + observerHeight;
const targElev = profile[profile.length - 1].elevation + targetHeight;
// Check each intermediate point
for (let i = 1; i < profile.length - 1; i++) {
const point = profile[i];
// Interpolate line-of-sight elevation at this distance
const t = point.distance / profile[profile.length - 1].distance;
const losElev = obsElev + t * (targElev - obsElev);
// Check if terrain obstructs
if (point.elevation > losElev) {
los.visible = false;
los.obstructions.push(point);
}
}
return los;
}Chart.js Profile Chart:
function createProfileChart(profile) {
const ctx = document.getElementById('profileChart').getContext('2d');
return new Chart(ctx, {
type: 'line',
data: {
labels: profile.map(p => p.distance.toFixed(2)),
datasets: [{
label: 'Elevation (m)',
data: profile.map(p => p.elevation),
borderColor: 'rgb(75, 192, 192)',
fill: {
target: 'origin',
above: 'rgba(75, 192, 192, 0.3)'
}
}]
},
options: {
responsive: true,
scales: {
x: {
title: {
display: true,
text: 'Distance (m)'
}
},
y: {
title: {
display: true,
text: 'Elevation (m)'
}
}
},
plugins: {
tooltip: {
callbacks: {
label: function(context) {
const point = profile[context.dataIndex];
return [
`Elevation: ${point.elevation.toFixed(2)} m`,
`Distance: ${point.distance.toFixed(2)} m`,
`Gain: ${point.elevationGain.toFixed(2)} m`
];
}
}
}
}
}
});
}Location: src/essence/Tools/Shade/ShadeTool.js
Key Files:
ShadeTool.js- Main controllerShadeTool_Manager.js- Manages multiple shade instancesShadeTool_Algorithm.js- Shadow computation
Backend Scripts:
ll2aerll.py- Lat/lon to azimuth/elevation conversion using SPICEchronos.py- Time conversion (UTC ↔ spacecraft time)
SPICE Integration:
# ll2aerll.py - Calculate azimuth/elevation to source body
import spiceypy as spice
def ll2aerll(observer_body, target_body, time, lat, lon, height):
# Load SPICE kernels
spice.furnsh('kernels/lsk/naif0012.tls')
spice.furnsh('kernels/pck/pck00010.tpc')
spice.furnsh('kernels/spk/de430.bsp')
# Observer position on surface
observer_pos = spice.georec(lon, lat, height, observer_body_radii, observer_body_flattening)
# Target position at time
target_state, _ = spice.spkezr(target_body, time, 'IAU_MARS', 'NONE', observer_body)
# Vector from observer to target
vector = target_state[:3] - observer_pos
# Convert to azimuth/elevation
azimuth, elevation, range = spice.recazl(vector)
return {
'azimuth': np.degrees(azimuth),
'elevation': np.degrees(elevation),
'range': range
}Shadow Calculation:
async function computeShadowMap(observer, sourceBody, time, demTiles) {
// Get source position from SPICE
const sourcePos = await fetch('/api/spice/ll2aerll', {
method: 'POST',
body: JSON.stringify({
observer: observer,
target: sourceBody,
time: time,
lat: observer.lat,
lon: observer.lon,
height: observer.height
})
}).then(r => r.json());
const sourceAzimuth = sourcePos.azimuth;
const sourceElevation = sourcePos.elevation;
// For each tile pixel
const shadowMap = [];
for (const tile of demTiles) {
for (let i = 0; i < tile.width; i++) {
for (let j = 0; j < tile.height; j++) {
const pixel = tile.getPixel(i, j);
// Ray-trace from pixel to source
const shadowed = raytrace(pixel, sourceAzimuth, sourceElevation, demTiles);
shadowMap.push({
lat: pixel.lat,
lon: pixel.lon,
visible: !shadowed,
color: shadowed ? 'red' : 'green'
});
}
}
}
return shadowMap;
}
function raytrace(pixel, azimuth, elevation, demTiles) {
// Cast ray from pixel towards source
const direction = {
x: Math.cos(elevation) * Math.sin(azimuth),
y: Math.cos(elevation) * Math.cos(azimuth),
z: Math.sin(elevation)
};
let current = pixel;
const stepSize = 1; // meter
while (current.elevation < 10000) { // Max height
current.x += direction.x * stepSize;
current.y += direction.y * stepSize;
current.z += direction.z * stepSize;
// Get terrain height at current position
const terrainHeight = sampleDEM(current.x, current.y, demTiles);
// Check if ray is below terrain
if (current.z < terrainHeight) {
return true; // Shadowed
}
}
return false; // Visible
}Location: src/essence/Tools/Sites/SitesTool.js
Configuration:
{
"sites": [
{
"name": "Landing Site",
"code": "LS",
"lat": 18.445,
"lng": 77.451,
"zoom": 14
},
{
"name": "Sample Cache",
"code": "SC",
"lat": 18.450,
"lng": 77.455,
"zoom": 16
}
]
}Implementation:
function createSiteButtons(sites) {
const buttonBar = $('<div class="site-buttons"></div>');
sites.forEach(site => {
const button = $(`
<button class="site-button" data-code="${site.code}">
${site.name}
</button>
`);
button.on('click', () => navigateToSite(site));
buttonBar.append(button);
});
$('#sitesToolContent').append(buttonBar);
}
function navigateToSite(site) {
// Pan/zoom to site
Map_.setView([site.lat, site.lng], site.zoom);
// Toggle header layers by site code
if (site.code) {
L_.getAllLayers().forEach(layer => {
if (layer.type === 'header' && layer.code === site.code) {
L_.toggleLayer(layer.uuid, true);
}
});
}
// Highlight active button
$('.site-button').removeClass('active');
$(`.site-button[data-code="${site.code}"]`).addClass('active');
}Location: src/essence/Tools/Viewshed/ViewshedTool.js
Key Files:
ViewshedTool.js- Main controllerViewshedTool_Manager.js- Manages multiple viewshed instancesViewshedTool_Algorithm.js- Visibility computation
Algorithm (Ray-Tracing):
function computeViewshed(observer, cameraParams, demTiles) {
const viewshed = [];
const {
height,
azimuthCenter,
azimuthFOV,
elevationCenter,
elevationFOV,
maxRange
} = cameraParams;
// Observer position
const observerElev = observer.elevation + height;
// For each cell in FOV
for (let az = azimuthCenter - azimuthFOV/2; az <= azimuthCenter + azimuthFOV/2; az += 1) {
for (let el = elevationCenter - elevationFOV/2; el <= elevationCenter + elevationFOV/2; el += 1) {
// Cast ray
const cells = castRay(observer, observerElev, az, el, maxRange, demTiles);
cells.forEach(cell => {
if (!viewshed.find(v => v.lat === cell.lat && v.lon === cell.lon)) {
viewshed.push({
lat: cell.lat,
lon: cell.lon,
visible: cell.visible,
color: cell.visible ? 'green' : 'red'
});
}
});
}
}
return viewshed;
}
function castRay(observer, observerElev, azimuth, elevation, maxRange, demTiles) {
const cells = [];
const stepSize = 1; // meter
let distance = 0;
let visible = true;
while (distance < maxRange) {
distance += stepSize;
// Calculate position along ray
const lat = observer.lat + (distance * Math.cos(azimuth) / 111320);
const lon = observer.lon + (distance * Math.sin(azimuth) / (111320 * Math.cos(observer.lat)));
// Expected elevation along line-of-sight
const losElev = observerElev + distance * Math.tan(elevation);
// Actual terrain elevation
const terrainElev = sampleDEM(lat, lon, demTiles);
// Check visibility
if (terrainElev > losElev) {
visible = false;
}
cells.push({
lat: lat,
lon: lon,
elevation: terrainElev,
visible: visible
});
}
return cells;
}Camera Presets:
{
"cameraPresets": [
{
"name": "Mastcam",
"height": 2.0,
"azimuthCenter": 0,
"azimuthFOV": 90,
"elevationCenter": 0,
"elevationFOV": 60
},
{
"name": "Navcam",
"height": 1.5,
"azimuthCenter": 0,
"azimuthFOV": 120,
"elevationCenter": -10,
"elevationFOV": 45
}
]
}Planetary Curvature:
function applyPlanetaryCurvature(distance, planetRadius) {
// Correction for viewing over planetary surface
// Height of horizon at distance d from observer
return Math.sqrt(2 * planetRadius * distance + distance * distance);
}Location: src/essence/Basics/Map_/Map_.js
Tools interact with Map_ to:
- Add/remove Leaflet layers
- Register event handlers (click, mousemove, etc.)
- Pan/zoom programmatically
- Access map bounds and projection
Example:
// Tool adds layer to map
Map_.addLayer(myLayer);
// Tool registers click handler
Map_.on('click', (e) => {
tool.handleMapClick(e.latlng);
});
// Tool pans/zooms
Map_.setView([lat, lng], zoom);
// Tool queries map state
const bounds = Map_.getBounds();
const zoom = Map_.getZoom();Location: src/essence/Basics/Globe_/Globe_.js
3D-enabled tools integrate with Cesium Globe:
- Add 3D features (models, clamped vectors)
- Register globe events
- Control camera
- Terrain interaction
Example:
// Draw Tool adds clamped features
Globe_.litho.addLayer('clamped', {
name: 'drawTool_layer',
geojson: features,
opacity: 1
});
// Measure Tool queries terrain height
const height = Globe_.scene.globe.getHeight(Cesium.Cartographic.fromDegrees(lng, lat));
// Viewshed Tool controls camera
Globe_.scene.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(lng, lat, 1000),
orientation: {
heading: azimuth,
pitch: elevation,
roll: 0
}
});Location: src/essence/Basics/Layers_/Layers_.js
All tools respect layer system:
- Check layer visibility
- React to layer toggle events
- Filter behavior by active layers
Example:
// Identifier Tool checks which layers are active
const activeLayers = L_.getActiveLayers();
activeLayers.forEach(layer => {
if (layer.identifier) {
queryRasterValue(lat, lng, layer.identifier);
}
});
// Measure Tool gets DEM from layer config
const dem = L_.getLayerDEM(layerUuid);
// Layers Tool toggles layers
L_.toggleLayer(layerUuid, true); // Turn on
L_.setLayerOpacity(layerUuid, 0.5);GDAL Services (/api/gdal/raster):
- DEM querying for elevation (Measure, Identifier, Viewshed, Shade, Isochrone)
- Raster pixel value extraction (Identifier)
- Tile serving
File Management (/api/files):
- Draw Tool CRUD operations
- File sharing and permissions
- History tracking
SPICE Services (/api/spice/):
- Spacecraft position calculation (Shade)
- Time conversion (Shade)
Tools processing large spatial datasets (Viewshed, Shade, Isochrone) use tiled approaches:
- Limit memory consumption
- Enable progressive rendering
- Support multiple zoom levels
Implementation:
function loadTilesForViewport(bounds, zoom) {
const tiles = getTileCoordinates(bounds, zoom);
return Promise.all(
tiles.map(({z, x, y}) => {
const url = `${demUrl}/${z}/${x}/${y}.1bto4b`;
return fetch(url).then(r => r.arrayBuffer()).then(parse1bto4b);
})
);
}Where possible, tools perform client-side computation:
- Reduce server load
- Provide immediate feedback
- Work offline (partially)
Examples:
- Vector styling (Draw Tool)
- Feature filtering (Layers Tool)
- Distance calculation (Measure Tool)
- Viewshed ray-tracing (Viewshed Tool)
Tools are loaded on-demand:
- Reduce initial bundle size
- Improve page load time
- Load only needed dependencies
Webpack Dynamic Imports:
// Tool only loaded when activated
const DrawTool = await import('./Tools/Draw/DrawTool.js');Draw Tool (DrawTool.test.js):
describe('Draw Tool', () => {
test('Initialize tool', () => {
const tool = DrawTool.make();
expect(tool).toBeDefined();
});
test('Create file', async () => {
const file = await DrawTool.createFile('Test File', 'roi');
expect(file.id).toBeDefined();
});
test('Clip features', async () => {
const clipped = await DrawTool.clipFeatures(newGeom, existingGeoms, 'over');
expect(clipped.geometry).toBeDefined();
});
});Tools should be tested for:
- ToolController integration (lifecycle)
- Map/Globe integration (rendering)
- Backend API integration (data operations)
- URL state persistence (bookmarking)
Webpack (webpack.config.js):
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'public'),
filename: 'MMGIS.js'
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
}
]
}
};Tools may require environment-specific configuration:
- API endpoints
- SPICE kernel paths
- DEM tileset URLs
- Authentication settings
Example (.env):
GDAL_API_URL=http://localhost:8888/api/gdal
SPICE_API_URL=http://localhost:8888/api/spice
DEM_BASE_URL=https://tiles.example.com/dems
- Tool Presets - Save and restore tool configurations
- Tool Workflows - Chain multiple tools in sequence
- Tool Export - Export tool outputs to standard formats
- Tool Collaboration - Real-time collaborative tool usage
- Tool Analytics - Track tool usage and performance
- Unified Tool State Manager - Centralized tool state management
- Unified Tool API - Standardize additional lifecycle hooks (onResize, onLayerChange, etc.)
- Tool Communication - Enable inter-tool messaging
- Tool Dependencies - Declare and resolve tool dependencies
Version: 2.0 Date: 2025-12-22 Status: Active
Changes:
- Expanded to document all 15 user-facing tools individually
- Merged comprehensive Draw Tool content from spec 003
- Added brief architectural note about Kinds system
- Comprehensive implementation details for each tool
- Architecture notes (React vs jQuery, libraries used, file structure)
- Test coverage notes where applicable