Files
MoundHunters/ui/src/App.vue

1786 lines
54 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div id="app">
<div ref="mapContainer" class="map-container"></div>
<ShadingSandbox
:visible="sandboxVisible"
:offscreen="sandboxOffscreen"
:initial-settings="DEFAULT_RENDER_SETTINGS"
ref="sandboxRef"
@close="sandboxVisible = false"
@renderComplete="onRenderComplete"
@error="onRenderError"
/>
<div class="layer-controls">
<div class="control-section">
<label>Base Map:</label>
<label><input type="radio" value="osm" v-model="baseLayer" @change="updateBaseLayer"> Street</label>
<label><input type="radio" value="satellite" v-model="baseLayer" @change="updateBaseLayer"> Satellite</label>
</div>
<div class="control-section">
<label class="section-header" @click="historicMarkersExpanded = !historicMarkersExpanded" style="cursor: pointer;">
{{ historicMarkersExpanded ? '▼' : '▶' }} Historic Markers
</label>
<div v-if="historicMarkersExpanded" class="subsection">
<button class="hide-all-btn" @click="hideAllHistoricMarkers">
{{ allHistoricMarkersHidden ? 'Show All' : 'Hide All' }}
</button>
<div v-for="site in KNOWN_SITES" :key="site.name" class="site-control">
<label>
<input type="checkbox" v-model="visibleSites[site.name]" @change="toggleSite(site.name)">
{{ site.name }}
</label>
<button class="jump-btn" @click="jumpToSite(site)" title="Jump to location">📍</button>
</div>
</div>
</div>
<div class="control-section">
<label>
<input type="checkbox" v-model="showLidar" @change="toggleLidar">
Show Lidar
</label>
<div v-if="showLidar" class="slider-control">
<label>Opacity: {{ Math.round(lidarOpacity) }}%</label>
<input
type="range"
min="0"
max="100"
v-model.number="lidarOpacity"
@input="updateLidarOpacity"
class="opacity-slider"
>
</div>
</div>
<div class="control-section">
<label>
<input type="checkbox" v-model="showGeometry" @change="toggleGeometry">
Show Geometry
</label>
<label>
<input type="checkbox" v-model="imperialUnits">
Imperial Units
</label>
</div>
<div class="control-section">
<button class="sandbox-btn" @click="openSandbox">Open Shading Sandbox</button>
</div>
</div>
<div class="geometry-toolbar">
<button
:class="['tool-btn', { active: drawMode === 'line' }]"
@click="setDrawMode('line')"
title="Draw Line"
>
📏 Line
</button>
<button
:class="['tool-btn', { active: drawMode === 'ray' }]"
@click="setDrawMode('ray')"
title="Draw Ray"
>
Ray
</button>
<button
class="tool-btn danger"
@click="clearAllGeometry"
title="Clear All Geometry"
>
🗑 Clear
</button>
</div>
<div
v-if="contextMenu.visible"
class="context-menu"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
>
<div class="context-menu-header">
{{ formatCoordinate(contextMenu.lngLat.lng, 'lng') }}, {{ formatCoordinate(contextMenu.lngLat.lat, 'lat') }}
<span v-if="contextMenu.tileName" class="tile-name">{{ contextMenu.tileName }}</span>
</div>
<button @click="dropPin" class="context-menu-item">📍 Drop Pin</button>
<button @click="startMeasure" class="context-menu-item">📏 Measure from here</button>
<button v-if="!contextMenu.hasTile" @click="requestTile" class="context-menu-item">📥 Request Tile</button>
<button v-if="contextMenu.hasTile && !contextMenu.hasMound" @click="loadTileFromContextMenu" class="context-menu-item">📦 Load Tile Data</button>
<button v-if="contextMenu.hasMound" @click="openSandboxFromContextMenu" class="context-menu-item">🔬 Open in Shading Sandbox</button>
</div>
<div
v-if="popup.visible"
class="popup"
:style="{ left: popup.x + 'px', top: popup.y + 'px' }"
>
<div class="popup-content">
<div v-if="popup.type === 'pin'">
<strong>Pin #{{ popup.feature.properties.number }}</strong>
<div>{{ formatCoordinate(popup.feature.geometry.coordinates[0], 'lng') }}, {{ formatCoordinate(popup.feature.geometry.coordinates[1], 'lat') }}</div>
<button @click="deleteFeature(popup.feature.id)" class="popup-btn danger">Delete</button>
</div>
<div v-else-if="popup.type === 'line' || popup.type === 'ray'">
<strong>{{ popup.type === 'line' ? 'Line' : 'Ray' }}</strong>
<div>Length: {{ formatDistance(popup.feature.properties.length) }}</div>
<div>Bearing: {{ popup.feature.properties.bearing.toFixed(1) }}°</div>
<button @click="deleteFeature(popup.feature.id)" class="popup-btn danger">Delete</button>
</div>
<div v-else-if="popup.type === 'site'" class="site-popup">
<strong>{{ popup.feature.properties.name }}</strong>
<div class="site-type">{{ popup.feature.properties.type === 'road_confirmed' ? 'Confirmed Road Segment' : 'Earthwork' }}</div>
<div class="site-description">{{ popup.feature.properties.description }}</div>
</div>
</div>
<button @click="popup.visible = false" class="popup-close">×</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
// Devmode rendering
//import { batchRenderTiles } from './batch-renderer.js';
//<button @click="batchRenderTiles(sandboxRef, tileCache, TEST_TILES)">SPAM</button>
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import ShadingSandbox from './ShadingSandbox.vue';
// Default render settings - used everywhere
const DEFAULT_RENDER_SETTINGS = {
azimuth: 90,
altitude: 60,
intensity: 1.2,
heightScale: 3,
terrainColor: 0x9A9996
};
// Refs
const mapContainer = ref(null);
const sandboxRef = ref(null);
const sandboxVisible = ref(false);
const sandboxOffscreen = ref(false);
const baseLayer = ref('osm');
const historicMarkersExpanded = ref(true);
const visibleSites = ref({});
const allHistoricMarkersHidden = ref(false);
const showLidar = ref(true);
const showGeometry = ref(true);
const imperialUnits = ref(false);
const lidarOpacity = ref(80);
const tileCache = ref({}); // Cached .mound data (for shading sandbox)
const loadedPngTiles = ref(new Set()); // Track which PNG tiles are displayed on map
const currentTileData = ref(null);
// Geometry state
const drawMode = ref(null); // 'line', 'ray', or null
const drawPoints = ref([]);
const geometryFeatures = ref({ type: 'FeatureCollection', features: [] });
const nextPinNumber = ref(1);
const nextFeatureId = ref(1);
// UI state
const contextMenu = ref({
visible: false,
x: 0,
y: 0,
lngLat: null,
hasTile: false, // Has PNG tile displayed
hasMound: false, // Has .mound data cached
tileName: null // The tile name at this location
});
const popup = ref({ visible: false, x: 0, y: 0, type: null, feature: null, lngLat: null });
// Map instance
let map = null;
let drawingHandler = null;
// BIBLIOGRAPHY
// BibTeX-inspired JSON format for Hopewell archaeological references
const BIBLIOGRAPHY = {
"hively_horn_1982": {
"type": "article",
"author": ["Hively, Ray", "Horn, Robert"],
"title": "Geometry and Astronomy in Prehistoric Ohio",
"journal": "Archaeoastronomy",
"volume": "4",
"pages": "S1-S20",
"year": 1982,
"note": "Foundational paper demonstrating lunar alignments at Newark Octagon Earthworks"
},
"hively_horn_1984": {
"type": "article",
"author": ["Hively, Ray", "Horn, Robert"],
"title": "Hopewellian Geometry and Astronomy at High Bank",
"journal": "Archaeoastronomy",
"volume": "7",
"pages": "S85-S100",
"year": 1984,
"note": "Extended lunar alignment analysis to High Bank Works"
},
"hively_horn_2006": {
"type": "article",
"author": ["Hively, Ray", "Horn, Robert"],
"title": "A Statistical Study of Lunar Alignments at the Newark Earthworks",
"journal": "Midcontinental Journal of Archaeology",
"year": 2006,
"note": "Monte Carlo analysis showing odds of chance alignments at 1 in 40 million"
},
"squier_davis_1848": {
"type": "book",
"author": ["Squier, Ephraim George", "Davis, Edwin Hamilton"],
"title": "Ancient Monuments of the Mississippi Valley",
"publisher": "Smithsonian Institution",
"series": "Smithsonian Contributions to Knowledge",
"volume": "1",
"year": 1848,
"note": "First comprehensive survey of Ohio earthworks; first Smithsonian publication"
},
"salisbury_salisbury_1862": {
"type": "article",
"author": ["Salisbury, James", "Salisbury, Charles"],
"title": "Accurate Surveys and Descriptions of the Ancient Earthworks at Newark, Ohio",
"journal": "American Journal of Science and Arts",
"series": "2nd series",
"volume": "34",
"pages": "61-71",
"year": 1862,
"note": "First documentation tracing the Great Hopewell Road 6 miles south from Newark"
},
"lepper_1995": {
"type": "article",
"author": ["Lepper, Bradley T."],
"title": "Tracking Ohio's Great Hopewell Road",
"journal": "Archaeology",
"volume": "48",
"number": "6",
"pages": "52-56",
"year": 1995,
"note": "Modern investigation of the Great Hopewell Road hypothesis"
},
"lepper_2006": {
"type": "incollection",
"author": ["Lepper, Bradley T."],
"title": "The Great Hopewell Road and the Role of Pilgrimage in the Hopewell Interaction Sphere",
"booktitle": "Recreating Hopewell",
"editor": ["Charles, Douglas K.", "Buikstra, Jane E."],
"publisher": "University Press of Florida",
"year": 2006
},
"lepper_2024": {
"type": "article",
"author": ["Lepper, Bradley T."],
"title": "The Great Hopewell Road: A Biased Assessment Thirty Years On",
"journal": "Journal of Ohio Archaeology",
"volume": "10",
"year": 2024
},
"magli_lepper_2025": {
"type": "article",
"author": ["Magli, Giulio", "Lepper, Bradley T."],
"title": "Going Straight in a Sacred Landscape: The Great Hopewell Road",
"journal": "Studies in Digital Heritage",
"volume": "9",
"number": "1",
"pages": "37-54",
"year": 2025
},
"schwarz_2016": {
"type": "article",
"author": ["Schwarz, Kevin R."],
"title": "The Great Hopewell Road: New Data, Analysis, and Future Research Prospects",
"journal": "Journal of Ohio Archaeology",
"volume": "4",
"pages": "12-38",
"year": 2016
},
"romain_burks_2008": {
"type": "article",
"author": ["Romain, William F.", "Burks, Jarrod"],
"title": "LiDAR Imaging of the Great Hopewell Road",
"journal": "Ohio Archaeology",
"year": 2008,
"note": "Early application of LiDAR to Van Voorhis Walls analysis"
},
"mickelson_lepper_2007": {
"type": "article",
"author": ["Mickelson, Mark E.", "Lepper, Bradley T."],
"title": "Observational Archaeoastronomy at the Newark Earthworks",
"journal": "Mediterranean Archaeology and Archaeometry",
"volume": "6",
"number": "3",
"pages": "173-179",
"year": 2007,
"note": "Direct observations confirming lunar alignments during 2004-2007 standstill cycle"
},
"romain_2000": {
"type": "book",
"author": ["Romain, William F."],
"title": "Mysteries of the Hopewell: Astronomers, Geometers, and Magicians of the Eastern Woodlands",
"publisher": "University of Akron Press",
"year": 2000
},
"lynott_2015": {
"type": "book",
"author": ["Lynott, Mark J."],
"title": "Hopewell Ceremonial Landscapes of Ohio: More than Mounds and Geometric Earthworks",
"series": "American Landscapes Series",
"publisher": "Oxbow Books",
"year": 2015
},
"moorehead_1892": {
"type": "book",
"author": ["Moorehead, Warren K."],
"title": "Primitive Man in Ohio",
"publisher": "G.P. Putnam's Sons",
"year": 1892,
"note": "Excavations at Hopewell Mound Group that gave the culture its name"
},
"shetrone_greenman_1931": {
"type": "article",
"author": ["Shetrone, Henry C.", "Greenman, Emerson F."],
"title": "Explorations of the Seip Group of Prehistoric Earthworks",
"journal": "Ohio State Archaeological and Historical Quarterly",
"volume": "40",
"pages": "343-509",
"year": 1931
},
"mills_1909": {
"type": "article",
"author": ["Mills, William C."],
"title": "Explorations of the Seip Mound",
"journal": "Ohio State Archaeological and Historical Quarterly",
"volume": "18",
"pages": "269-321",
"year": 1909
},
"atwater_1820": {
"type": "book",
"author": ["Atwater, Caleb"],
"title": "Descriptions of the Antiquities Discovered in the State of Ohio and Other Western States",
"publisher": "American Antiquarian Society",
"year": 1820,
"note": "Early speculation that southwesterly road extended at least 30 miles"
},
"nps_hocu": {
"type": "misc",
"author": ["National Park Service"],
"title": "Hopewell Culture National Historical Park",
"url": "https://www.nps.gov/hocu/",
"note": "Official NPS documentation for park sites"
},
"unesco_2023": {
"type": "misc",
"author": ["UNESCO"],
"title": "Hopewell Ceremonial Earthworks",
"url": "https://whc.unesco.org/en/list/1689/",
"year": 2023,
"note": "World Heritage Site inscription documentation"
},
"ohc_newark": {
"type": "misc",
"author": ["Ohio History Connection"],
"title": "Newark Earthworks",
"url": "https://www.ohiohistory.org/visit/browse-historical-sites/newark-earthworks/",
"note": "Official Ohio History Connection site documentation"
}
};
// KNOWN_SITES with citations added to descriptions
// Citations use \cite{key} format for easy parsing
const KNOWN_SITES = [
{
"name": "Newark Octagon Earthworks",
"coordinates": [[-82.4463745, 40.0519828]],
"description": "Part of the Newark Earthworks complex, the Octagon is precisely aligned to the 18.6-year lunar cycle \\cite{hively_horn_1982}. Connected to a 20-acre Observatory Circle, this geometric earthwork demonstrates sophisticated astronomical knowledge. The walls and gateways encode all eight lunar standstill rise and set points \\cite{mickelson_lepper_2007}. The Octagon's eight walls (each approximately 550 feet long) and Observatory Circle form one of only two known circle-octagon pairs in the Hopewell world, the other being High Bank Works \\cite{hively_horn_1984}. First comprehensively surveyed by Squier and Davis in the 1840s \\cite{squier_davis_1848}. Inscribed as a UNESCO World Heritage Site in September 2023 \\cite{unesco_2023}.",
"type": "earthwork",
"tiles": [
'BS19820747',
'BS19820748',
'BS19830747',
'BS19830748',
'BS19820746',
'BS19810747',
'BS19810746',
'BS19860742',
'BS19870743',
'BS19860743',
'BS19880743',
'BS19880742'
],
"overlay": [
{
"type": 'polygon',
"coordinates": [
[-82.44123, 40.05443],
[-82.44260, 40.05309],
[-82.44464, 40.05237],
[-82.44631, 40.05342],
[-82.44728, 40.05500],
[-82.44589, 40.05633],
[-82.44389, 40.05698],
[-82.44216, 40.05595],
[-82.44123, 40.05443]
]
}
]
},
{
"name": "Great Circle Earthworks",
"coordinates": [[-82.4277555, 40.0402671]],
"description": "A nearly perfect circle 1,200 feet in diameter, enclosing approximately 30 acres \\cite{squier_davis_1848}. The earthen wall is lined by a deep interior ditch, a design typical of earlier Adena earthworks \\cite{lynott_2015}. Located in Heath, Ohio, this is one of the largest circular earthworks in the Americas. Eagle Mound at the center covers the remains of a large ceremonial structure \\cite{ohc_newark}. The walls vary from 4 to 14 feet in height at the monumental gateway. Unlike the Octagon, no solar alignments have been confirmed at the Great Circle \\cite{hively_horn_1982}. Part of the Hopewell Ceremonial Earthworks UNESCO World Heritage Site \\cite{unesco_2023}.",
"type": "earthwork",
"tiles": ["BS19870742", 'BS19870743', 'BS19880743']
},
{
"name": "Van Voorhis Walls",
"coordinates": [[-82.446375, 40.051983], [-82.447, 40.048], [-82.448, 40.045], [-82.45, 40.04]],
"description": "The best-preserved section of the Great Hopewell Road, extending 2.5 miles south from the Newark Octagon to Ramp Creek \\cite{lepper_1995}. This confirmed earthwork consists of parallel walls approximately 60 meters (200 feet) apart, aligned on an azimuth of approximately 212° toward Chillicothe \\cite{schwarz_2016}. First documented by James and Charles Salisbury in 1862, who followed the walls for 6 miles through 'tangled swamps and over hills, still keeping their undeviating course' \\cite{salisbury_salisbury_1862}. LiDAR analysis suggests the road was sunken between the walls \\cite{romain_burks_2008}. Test excavations in 2009 revealed a thin layer of white limestone that may have paved the road \\cite{lepper_2024}. Still visible above ground in woodland areas too swampy to farm.",
"type": "road_confirmed",
"tiles": ['BS19820746', 'BS19820745', 'BS19820743', 'BS19820742', 'BS19860742', 'BS19880742']
},
{
"name": "Mound City Group",
"coordinates": [[-83.0065767, 39.3744923]],
"description": "The headquarters of Hopewell Culture National Historical Park \\cite{nps_hocu}. Contains 23 burial mounds within a nearly square earthen enclosure (walls approximately 3-4 feet high) along the Scioto River, enclosing over 13 acres \\cite{squier_davis_1848}. Each mound covered a charnel house where the dead were cremated. Excavations by Squier and Davis in the 1840s, and later Ohio Historical Society work (1920-1922), revealed spectacular artifacts including effigy smoking pipes, mica sheets, copper figures, and obsidian from Yellowstone \\cite{lynott_2015}. Much was damaged during Camp Sherman construction in WWI; mounds were reconstructed in the 1920s. Part of the Hopewell Ceremonial Earthworks UNESCO World Heritage Site \\cite{unesco_2023}.",
"type": "earthwork",
"tiles": ['BS18250500', 'BS18250501', 'BS18260501', 'BS18260500']
},
{
"name": "Hopeton Earthworks",
"coordinates": [[-82.9809185, 39.3790743]],
"description": "A geometric earthwork complex featuring a circle (320m/1,050 ft diameter) and square of similar area, connected by parallel earthen walls \\cite{squier_davis_1848}. The circle has the same diameter as those at four other Hopewell sites, including Newark and High Bank \\cite{hively_horn_1984}. The parallel walls (extending nearly half a mile toward the Scioto River) align with the winter solstice sunset, and the diagonal of the square aligns with the summer solstice sunset \\cite{nps_hocu}. Located on a terrace east of the Scioto River, across from Mound City \\cite{lynott_2015}. Recent magnetometry revealed evidence of a monumental 'woodhenge' with giant posts spaced at 20-foot intervals around the circle. Part of the Hopewell Ceremonial Earthworks UNESCO World Heritage Site \\cite{unesco_2023}.",
"type": "earthwork",
"tiles": ['BS18320502', 'BS18320503', 'BS18320505', 'BS18330505', 'BS18330503']
},
{
"name": "Hopewell Mound Group",
"coordinates": [[-83.0844809, 39.3608166]],
"description": "The type site for the Hopewell culture, named after former landowner Mordecai Cloud Hopewell \\cite{moorehead_1892}. This 300-acre site contains 29 mounds within a parallelogram enclosure of approximately 111 acres \\cite{squier_davis_1848}. Includes the largest known Hopewell mound—originally 500 feet long, 180 feet wide, and 30 feet tall, consisting of three conjoined mounds within a D-shaped enclosure \\cite{nps_hocu}. More Hopewell artifacts of the highest quality were found here than at any other site, including mica cutouts, copper effigies, and obsidian blades \\cite{lynott_2015}. First excavated by Warren Moorehead in 1891-1892. Part of the Hopewell Ceremonial Earthworks UNESCO World Heritage Site \\cite{unesco_2023}.",
"type": "earthwork",
"tiles": ['BS18020495', 'BS18020496', 'BS18010496', 'BS18010495', 'BS18000496', 'BS18000495', 'BS17980496', 'BS17980495']
},
{
"name": "Seip Earthworks",
"coordinates": [[-83.2214086, 39.2416867]],
"description": "One of the largest Hopewell complexes, featuring two circles and a square enclosing approximately 121 acres with over 10,000 feet of embankment walls \\cite{squier_davis_1848}. The Seip-Pricer Mound stands 30 feet high (240 feet long, 160 feet wide), one of the largest burial mounds in the Middle Ohio Valley \\cite{shetrone_greenman_1931}. The square measures exactly 27 acres, matching four other nearby Hopewell sites, suggesting a common unit of measurement \\cite{romain_2000}. Excavations (1925-1928) revealed over 100 burials with artifacts including thousands of freshwater pearls, Isle Royale copper, Carolina mica, and Tennessee River Valley effigy pipes \\cite{mills_1909}. Part of the Hopewell Ceremonial Earthworks UNESCO World Heritage Site \\cite{unesco_2023}.",
"type": "earthwork",
"tiles": ['BS17630452', 'BS17630451', 'BS17650451', 'BS17620451', 'BS17620450']
},
// There is something in this area, but I can't confirm it's the 'high banks works'
// Google maps and some facebook boomer do claim so, "highbank park earthworks"
// A historical map puts it near the Scioto river, but that's on the other side of columbus
{
"name": "High Bank Works",
"coordinates": [[-83.028353, 40.139853]],
"description": "Features a circle-octagon pair with the same design principles as Newark Earthworks—both circles are exactly 1,054 feet in diameter \\cite{hively_horn_1984}. Located approximately 60 miles from Newark on a terrace 75-80 feet above the Scioto River. The octagon is aligned to the lunar standstill cycle, with its main axis rotated exactly 90° from Newark's orientation \\cite{hively_horn_2006}. Encodes all eight lunar standstill points plus the four solstices \\cite{romain_2000}. The only other circle-octagon combination built by the Hopewell culture, suggesting intentional pairing with Newark \\cite{magli_lepper_2025}. Currently a research preserve within Hopewell Culture National Historical Park, not routinely open to the public \\cite{nps_hocu}. Part of the Hopewell Ceremonial Earthworks UNESCO World Heritage Site \\cite{unesco_2023}.",
"type": "earthwork",
"tiles": [
"BS821780",
"BS820780",
"BS18210778",
"BS18200778",
"N1820175"
]
}
];
const TEST_TILES = KNOWN_SITES.flatMap(i=>i.tiles);
// Export for use in application
if (typeof module !== 'undefined' && module.exports) {
module.exports = { KNOWN_SITES, BIBLIOGRAPHY };
}
// Tile names to load - merged from all KNOWN_SITES
const TILE_NAMES = (() => {
const allTiles = new Set();
KNOWN_SITES.forEach(site => {
if (site.tiles) {
site.tiles.forEach(tile => allTiles.add(tile));
}
});
return Array.from(allTiles);
})();
const octagonCenter = [-82.44383, 40.05469];
// Coordinate conversion utilities
function webMercatorToLonLat(x, y) {
const R = 6378137; // Earth's radius in meters
const lon = (x / R) * (180 / Math.PI);
const lat = (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * (180 / Math.PI);
return [lon, lat];
}
// Calculate distance between two points in meters (Haversine formula)
function calculateDistance(lng1, lat1, lng2, lat2) {
const R = 6371000; // Earth's radius in meters
const φ1 = lat1 * Math.PI / 180;
const φ2 = lat2 * Math.PI / 180;
const Δφ = (lat2 - lat1) * Math.PI / 180;
const Δλ = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in meters
}
// Calculate bearing between two points in degrees
function calculateBearing(lng1, lat1, lng2, lat2) {
const φ1 = lat1 * Math.PI / 180;
const φ2 = lat2 * Math.PI / 180;
const Δλ = (lng2 - lng1) * Math.PI / 180;
const y = Math.sin(Δλ) * Math.cos(φ2);
const x = Math.cos(φ1) * Math.sin(φ2) -
Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
const θ = Math.atan2(y, x);
return (θ * 180 / Math.PI + 360) % 360; // Bearing in degrees
}
// Check if a point has a loaded lidar tile, return tile info
function getTileInfoAtPoint(lng, lat) {
if (!map) return { hasTile: false, hasMound: false, tileName: null };
// Check all loaded PNG tile layers
for (const tileName of loadedPngTiles.value) {
const layerId = `png-tile-layer-${tileName}`;
if (!map.getLayer(layerId)) continue;
const source = map.getSource(`png-tile-${tileName}`);
if (!source) continue;
// Get the bounds from the image source coordinates
// coordinates are: [[topLeft], [topRight], [bottomRight], [bottomLeft]]
const coords = source.coordinates;
if (!coords || coords.length !== 4) continue;
const [topLeft, topRight, bottomRight, bottomLeft] = coords;
const minLng = Math.min(topLeft[0], bottomLeft[0]);
const maxLng = Math.max(topRight[0], bottomRight[0]);
const minLat = Math.min(bottomLeft[1], bottomRight[1]);
const maxLat = Math.max(topLeft[1], topRight[1]);
// Check if point is within bounds
if (lng >= minLng && lng <= maxLng && lat >= minLat && lat <= maxLat) {
return {
hasTile: true,
hasMound: !!tileCache.value[tileName],
tileName: tileName
};
}
}
return { hasTile: false, hasMound: false, tileName: null };
}
// Legacy wrapper for compatibility
function hasLidarAtPoint(lng, lat) {
return getTileInfoAtPoint(lng, lat).hasTile;
}
// Extend a line from point1 through point2 to map bounds
function extendRay(lng1, lat1, lng2, lat2, bounds) {
const bearing = calculateBearing(lng1, lat1, lng2, lat2);
const bearingRad = bearing * Math.PI / 180;
// Calculate a far point (100km away)
const R = 6371000; // Earth's radius in meters
const d = 100000; // 100km
const φ1 = lat1 * Math.PI / 180;
const λ1 = lng1 * Math.PI / 180;
const φ2 = Math.asin(Math.sin(φ1) * Math.cos(d / R) +
Math.cos(φ1) * Math.sin(d / R) * Math.cos(bearingRad));
const λ2 = λ1 + Math.atan2(Math.sin(bearingRad) * Math.sin(d / R) * Math.cos(φ1),
Math.cos(d / R) - Math.sin(φ1) * Math.sin(φ2));
return [λ2 * 180 / Math.PI, φ2 * 180 / Math.PI];
}
// Format coordinate based on imperial units setting
function formatCoordinate(value, type) {
return value.toFixed(6) + (type === 'lng' ? '° E' : '° N');
}
// Format distance based on imperial units setting
function formatDistance(meters) {
if (imperialUnits.value) {
const feet = meters * 3.28084;
if (feet > 5280) {
return (feet / 5280).toFixed(2) + ' mi';
}
return feet.toFixed(1) + ' ft';
} else {
if (meters > 1000) {
return (meters / 1000).toFixed(2) + ' km';
}
return meters.toFixed(1) + ' m';
}
}
// localStorage helpers
function saveGeometry() {
localStorage.setItem('hopewellGeometry', JSON.stringify(geometryFeatures.value));
localStorage.setItem('hopewellNextPinNumber', nextPinNumber.value.toString());
localStorage.setItem('hopewellNextFeatureId', nextFeatureId.value.toString());
}
function loadGeometry() {
const saved = localStorage.getItem('hopewellGeometry');
if (saved) {
try {
geometryFeatures.value = JSON.parse(saved);
} catch (e) {
console.error('Failed to load geometry:', e);
}
}
const savedPinNumber = localStorage.getItem('hopewellNextPinNumber');
if (savedPinNumber) {
nextPinNumber.value = parseInt(savedPinNumber);
}
const savedFeatureId = localStorage.getItem('hopewellNextFeatureId');
if (savedFeatureId) {
nextFeatureId.value = parseInt(savedFeatureId);
}
}
// Parse .mound binary file
async function parseMoundFile(url) {
const response = await fetch(url);
const buffer = await response.arrayBuffer();
const view = new DataView(buffer);
let offset = 0;
const magic = String.fromCharCode(
view.getUint8(offset++),
view.getUint8(offset++),
view.getUint8(offset++),
view.getUint8(offset++)
);
if (magic !== 'LIDR') {
throw new Error('Invalid .mound file');
}
const version = view.getUint32(offset, true); offset += 4;
const pointCount = view.getUint32(offset, true); offset += 4;
const triangleCount = view.getUint32(offset, true); offset += 4;
const minX = view.getFloat32(offset, true); offset += 4;
const minY = view.getFloat32(offset, true); offset += 4;
const minZ = view.getFloat32(offset, true); offset += 4;
const maxX = view.getFloat32(offset, true); offset += 4;
const maxY = view.getFloat32(offset, true); offset += 4;
const maxZ = view.getFloat32(offset, true); offset += 4;
offset += 24; // Skip reserved bytes
const positions = new Float32Array(buffer, offset, pointCount * 3);
offset += pointCount * 3 * 4;
const indices = new Uint32Array(buffer, offset, triangleCount * 3);
return {
version,
pointCount,
triangleCount,
bounds: { minX, minY, minZ, maxX, maxY, maxZ },
positions,
indices
};
}
// Load PNG tiles (pre-rendered hillshade images)
async function loadPngTiles() {
console.log('Loading PNG tiles...');
for (const tileName of TILE_NAMES) {
try {
// Fetch metadata JSON for bounds
const metaResponse = await fetch(`/png/${tileName}.json`);
if (!metaResponse.ok) {
console.warn(`No metadata for ${tileName}, skipping`);
continue;
}
const meta = await metaResponse.json();
// Check PNG exists
const pngUrl = `/png/${tileName}.png`;
const pngResponse = await fetch(pngUrl, { method: 'HEAD' });
if (!pngResponse.ok) {
console.warn(`No PNG for ${tileName}, skipping`);
continue;
}
// Convert bounds to lat/lon (assuming meta has bounds in web mercator)
const sw = webMercatorToLonLat(meta.bounds.minX, meta.bounds.minY);
const ne = webMercatorToLonLat(meta.bounds.maxX, meta.bounds.maxY);
// Add image source to map
map.addSource(`png-tile-${tileName}`, {
type: 'image',
url: pngUrl,
coordinates: [
[sw[0], ne[1]], // top-left
[ne[0], ne[1]], // top-right
[ne[0], sw[1]], // bottom-right
[sw[0], sw[1]] // bottom-left
]
});
// Add layer
map.addLayer({
id: `png-tile-layer-${tileName}`,
type: 'raster',
source: `png-tile-${tileName}`,
paint: {
'raster-opacity': lidarOpacity.value / 100
}
}, 'lidar-datum'); // Insert before datum layer
loadedPngTiles.value.add(tileName);
console.log(`Loaded PNG tile ${tileName} at [${sw[0].toFixed(5)}, ${sw[1].toFixed(5)}] - [${ne[0].toFixed(5)}, ${ne[1].toFixed(5)}]`);
} catch (err) {
console.error(`Failed to load PNG tile ${tileName}:`, err);
}
}
console.log(`Loaded ${loadedPngTiles.value.size} PNG tiles`);
}
// Load .mound data for a specific tile (for shading sandbox)
async function loadMoundData(tileName) {
if (tileCache.value[tileName]) {
console.log(`Mound data for ${tileName} already cached`);
return tileCache.value[tileName];
}
console.log(`Loading mound data for ${tileName}...`);
try {
const data = await parseMoundFile(`/mound/${tileName}.mound`);
tileCache.value[tileName] = data;
console.log(`Cached mound data for ${tileName}`);
return data;
} catch (err) {
console.error(`Failed to load mound data for ${tileName}:`, err);
throw err;
}
}
// Legacy function - now loads PNG tiles
async function loadLidarTiles() {
await loadPngTiles();
}
// UI handlers
function updateBaseLayer() {
if (!map) return;
if (baseLayer.value === 'osm') {
map.setLayoutProperty('osm-layer', 'visibility', 'visible');
map.setLayoutProperty('satellite-layer', 'visibility', 'none');
} else {
map.setLayoutProperty('osm-layer', 'visibility', 'none');
map.setLayoutProperty('satellite-layer', 'visibility', 'visible');
}
}
function toggleGeometry() {
if (!map) return;
const visibility = showGeometry.value ? 'visible' : 'none';
if (map.getLayer('geometry-pins')) {
map.setLayoutProperty('geometry-pins', 'visibility', visibility);
}
if (map.getLayer('geometry-lines')) {
map.setLayoutProperty('geometry-lines', 'visibility', visibility);
}
if (map.getLayer('geometry-labels')) {
map.setLayoutProperty('geometry-labels', 'visibility', visibility);
}
}
function toggleSite(siteName) {
if (!map) return;
const visible = visibleSites.value[siteName];
const visibility = visible ? 'visible' : 'none';
// Toggle marker
const markerLayerId = `site-marker-${siteName}`;
if (map.getLayer(markerLayerId)) {
map.setLayoutProperty(markerLayerId, 'visibility', visibility);
}
// Toggle all overlay geometries for this site (could be multiple with different indices)
const allLayers = map.getStyle().layers;
allLayers.forEach(layer => {
// Match overlay fill layers: site-overlay-${siteName}-${idx}
if (layer.id.startsWith(`site-overlay-${siteName}-`) &&
!layer.id.includes('-outline-')) {
map.setLayoutProperty(layer.id, 'visibility', visibility);
}
// Match overlay outline layers: site-overlay-outline-${siteName}-${idx}
if (layer.id.startsWith(`site-overlay-outline-${siteName}-`)) {
map.setLayoutProperty(layer.id, 'visibility', visibility);
}
});
}
function jumpToSite(site) {
if (!map) return;
const coords = site.coordinates[0];
map.flyTo({
center: coords,
zoom: 15,
duration: 1500
});
}
function hideAllHistoricMarkers() {
allHistoricMarkersHidden.value = !allHistoricMarkersHidden.value;
KNOWN_SITES.forEach(site => {
visibleSites.value[site.name] = !allHistoricMarkersHidden.value;
toggleSite(site.name);
});
}
function toggleLidar() {
if (!map) return;
const visibility = showLidar.value ? 'visible' : 'none';
for (const tileName of loadedPngTiles.value) {
const layerId = `png-tile-layer-${tileName}`;
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', visibility);
}
}
}
function updateLidarOpacity() {
if (!map) return;
const opacity = lidarOpacity.value / 100;
for (const tileName of loadedPngTiles.value) {
const layerId = `png-tile-layer-${tileName}`;
if (map.getLayer(layerId)) {
map.setPaintProperty(layerId, 'raster-opacity', opacity);
}
}
}
function openSandbox() {
sandboxVisible.value = true;
// If we have cached tile data, load it after sandbox mounts
setTimeout(() => {
if (sandboxRef.value && Object.keys(tileCache.value).length > 0) {
const firstTile = Object.keys(tileCache.value)[0];
sandboxRef.value.loadTileData(tileCache.value[firstTile]);
}
}, 100);
}
// Open sandbox with a specific tile
async function openSandboxWithTile(tileName) {
// Load mound data if not cached
if (!tileCache.value[tileName]) {
try {
await loadMoundData(tileName);
} catch (err) {
console.error('Failed to load tile for sandbox:', err);
return;
}
}
sandboxVisible.value = true;
setTimeout(() => {
if (sandboxRef.value) {
sandboxRef.value.loadTileData(tileCache.value[tileName]);
sandboxRef.value.setAvailableTiles(tileCache.value);
}
}, 100);
}
function onRenderComplete(data) {
console.log('Render complete:', {
size: `${data.width}x${data.height}`,
renderTime: data.renderTime,
settings: data.settings
});
}
function onRenderError(err) {
console.error('Renderer error:', err);
}
// Geometry functions
function setDrawMode(mode) {
if (drawMode.value === mode) {
drawMode.value = null;
drawPoints.value = [];
if (drawingHandler) {
map.off('click', drawingHandler);
drawingHandler = null;
}
} else {
drawMode.value = mode;
drawPoints.value = [];
// Remove old handler
if (drawingHandler) {
map.off('click', drawingHandler);
}
// Add new handler
drawingHandler = (e) => {
if (contextMenu.value.visible) return; // Ignore clicks while context menu is open
drawPoints.value.push([e.lngLat.lng, e.lngLat.lat]);
if (drawPoints.value.length === 2) {
completeDrawing();
}
};
map.on('click', drawingHandler);
}
}
function completeDrawing() {
const [pt1, pt2] = drawPoints.value;
const coords = drawMode.value === 'ray'
? [pt1, extendRay(pt1[0], pt1[1], pt2[0], pt2[1], map.getBounds())]
: [pt1, pt2];
const distance = calculateDistance(coords[0][0], coords[0][1], coords[1][0], coords[1][1]);
const bearing = calculateBearing(coords[0][0], coords[0][1], coords[1][0], coords[1][1]);
const feature = {
type: 'Feature',
id: `feature-${nextFeatureId.value++}`,
geometry: {
type: 'LineString',
coordinates: coords
},
properties: {
type: drawMode.value,
length: distance,
bearing: bearing
}
};
geometryFeatures.value.features.push(feature);
updateGeometryLayer();
saveGeometry();
// Reset
drawPoints.value = [];
setDrawMode(null);
}
function dropPin() {
const { lng, lat } = contextMenu.value.lngLat;
const feature = {
type: 'Feature',
id: `pin-${nextFeatureId.value++}`,
geometry: {
type: 'Point',
coordinates: [lng, lat]
},
properties: {
type: 'pin',
number: nextPinNumber.value++
}
};
geometryFeatures.value.features.push(feature);
updateGeometryLayer();
saveGeometry();
contextMenu.value.visible = false;
}
function startMeasure() {
contextMenu.value.visible = false;
setDrawMode('line');
const { lng, lat } = contextMenu.value.lngLat;
drawPoints.value = [[lng, lat]];
}
function deleteFeature(featureId) {
geometryFeatures.value.features = geometryFeatures.value.features.filter(
f => f.id !== featureId
);
updateGeometryLayer();
saveGeometry();
popup.value.visible = false;
}
function clearAllGeometry() {
if (confirm('Clear all pins, lines, and rays?')) {
geometryFeatures.value.features = [];
nextPinNumber.value = 1;
updateGeometryLayer();
saveGeometry();
}
}
// Request a tile for a specific location
async function requestTile() {
const { lng, lat } = contextMenu.value.lngLat;
// TODO: Call backend endpoint
// For now, just log what we would send
const requestData = {
longitude: lng,
latitude: lat,
// Backend will:
// 1. Transform to Ohio State Plane South (EPSG:3735) in US Survey Feet
// 2. Call https://maps.ohio.gov/arcgis/rest/services/OGRIP/3DepTiles/MapServer/0/query
// 3. Extract TileName, County, Block
// 4. Return tile info and download URL
};
console.log('=== Tile Request ===');
console.log('Would send to backend:', requestData);
console.log('Backend endpoint: POST /api/tiles/request');
// Close the context menu
contextMenu.value.visible = false;
// TODO: Show loading indicator
// TODO: Handle response and update UI
}
// Load mound data for interactive shading
async function loadTileFromContextMenu() {
const tileName = contextMenu.value.tileName;
contextMenu.value.visible = false;
if (!tileName) {
console.error('No tile name available');
return;
}
try {
await loadMoundData(tileName);
console.log(`Mound data loaded for ${tileName}, ready for shading sandbox`);
} catch (err) {
console.error('Failed to load mound data:', err);
// TODO: Show error toast
}
}
// Open shading sandbox for the tile at context menu location
async function openSandboxFromContextMenu() {
const tileName = contextMenu.value.tileName;
contextMenu.value.visible = false;
if (!tileName) {
console.error('No tile name available');
return;
}
await openSandboxWithTile(tileName);
}
function updateGeometryLayer() {
if (!map || !map.getSource('geometry')) return;
// Add id property to each feature for MapLibre
const dataWithIds = {
...geometryFeatures.value,
features: geometryFeatures.value.features.map(f => ({
...f,
properties: {
...f.properties,
id: f.id
}
}))
};
map.getSource('geometry').setData(dataWithIds);
}
// Lifecycle
onMounted(() => {
// Load saved geometry
loadGeometry();
map = new maplibregl.Map({
container: mapContainer.value,
style: {
version: 8,
sources: {
'osm': {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors'
},
'satellite': {
type: 'raster',
tiles: [
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
],
tileSize: 256,
attribution: 'Esri, Maxar, Earthstar Geographics'
}
},
layers: [
{
id: 'osm-layer',
type: 'raster',
source: 'osm',
layout: { visibility: 'visible' }
},
{
id: 'satellite-layer',
type: 'raster',
source: 'satellite',
layout: { visibility: 'none' }
},
{
id: 'lidar-datum',
type: 'background',
paint: { 'background-opacity': 0 }
}
]
},
center: octagonCenter,
zoom: 15
});
// Context menu handler
map.on('contextmenu', (e) => {
e.preventDefault();
const tileInfo = getTileInfoAtPoint(e.lngLat.lng, e.lngLat.lat);
contextMenu.value = {
visible: true,
x: e.point.x,
y: e.point.y,
lngLat: e.lngLat,
hasTile: tileInfo.hasTile,
hasMound: tileInfo.hasMound,
tileName: tileInfo.tileName
};
});
// Close context menu on regular click
map.on('click', (e) => {
// Check if clicking on geometry features first
const features = map.queryRenderedFeatures(e.point, {
layers: ['geometry-pins', 'geometry-lines']
});
if (features.length > 0 && !drawMode.value) {
const feature = features[0];
const featureData = geometryFeatures.value.features.find(f => f.id === feature.properties.id);
if (featureData) {
const coords = featureData.geometry.type === 'Point'
? featureData.geometry.coordinates
: featureData.geometry.coordinates[0]; // Use first point of line
popup.value = {
visible: true,
x: e.point.x,
y: e.point.y,
type: featureData.properties.type,
feature: featureData,
lngLat: coords
};
}
} else {
popup.value.visible = false;
}
contextMenu.value.visible = false;
});
// Close menus on ESC
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
contextMenu.value.visible = false;
popup.value.visible = false;
if (drawMode.value) {
setDrawMode(null);
}
}
});
// Change cursor for clickable geometry
map.on('mouseenter', 'geometry-pins', () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'geometry-pins', () => {
map.getCanvas().style.cursor = '';
});
map.on('mouseenter', 'geometry-lines', () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'geometry-lines', () => {
map.getCanvas().style.cursor = '';
});
map.on('load', async () => {
// Initialize visibility state for all sites
KNOWN_SITES.forEach(site => {
visibleSites.value[site.name] = true;
});
// Add historic site markers and overlays
KNOWN_SITES.forEach(site => {
// Add marker
const markerCoords = site.coordinates[0];
map.addSource(`site-marker-${site.name}`, {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: markerCoords
},
properties: {
name: site.name,
description: site.description,
type: site.type
}
}
});
map.addLayer({
id: `site-marker-${site.name}`,
type: 'circle',
source: `site-marker-${site.name}`,
paint: {
'circle-radius': 10,
'circle-color': site.type === 'road_confirmed' ? '#4A9EFF' : '#ff6b6b',
'circle-stroke-width': 3,
'circle-stroke-color': '#ffffff'
}
});
// Add overlay geometry if present
if (site.overlay) {
site.overlay.forEach((geom, idx) => {
map.addSource(`site-overlay-${site.name}-${idx}`, {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [geom.coordinates]
}
}
});
map.addLayer({
id: `site-overlay-${site.name}-${idx}`,
type: 'fill',
source: `site-overlay-${site.name}-${idx}`,
paint: {
'fill-color': site.type === 'road_confirmed' ? '#4A9EFF' : '#ff6b6b',
'fill-opacity': 0.2
}
});
map.addLayer({
id: `site-overlay-outline-${site.name}-${idx}`,
type: 'line',
source: `site-overlay-${site.name}-${idx}`,
paint: {
'line-color': site.type === 'road_confirmed' ? '#4A9EFF' : '#ff6b6b',
'line-width': 2
}
});
});
}
// Add polyline for multi-coordinate sites
if (site.coordinates.length > 1) {
map.addSource(`site-polyline-${site.name}`, {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: site.coordinates
}
}
});
map.addLayer({
id: `site-polyline-${site.name}`,
type: 'line',
source: `site-polyline-${site.name}`,
paint: {
'line-color': '#4A9EFF',
'line-width': 3,
'line-dasharray': [2, 2]
}
});
}
// Add click handler for marker popups
map.on('click', `site-marker-${site.name}`, (e) => {
e.preventDefault();
popup.value = {
visible: true,
x: e.point.x,
y: e.point.y,
type: 'site',
feature: {
properties: {
name: site.name,
description: site.description,
type: site.type
}
},
lngLat: site.coordinates[0]
};
});
// Cursor pointer on hover
map.on('mouseenter', `site-marker-${site.name}`, () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', `site-marker-${site.name}`, () => {
map.getCanvas().style.cursor = '';
});
});
// Update popup position when map moves or zooms
const updatePopupPosition = () => {
if (popup.value.visible && popup.value.lngLat) {
const point = map.project(popup.value.lngLat);
popup.value.x = point.x;
popup.value.y = point.y;
}
};
map.on('move', updatePopupPosition);
map.on('zoom', updatePopupPosition);
// Add geometry source and layers
map.addSource('geometry', {
type: 'geojson',
data: geometryFeatures.value
});
// Lines and rays
map.addLayer({
id: 'geometry-lines',
type: 'line',
source: 'geometry',
filter: ['in', ['get', 'type'], ['literal', ['line', 'ray']]],
paint: {
'line-color': '#0080ff',
'line-width': 3
}
});
// Pins
map.addLayer({
id: 'geometry-pins',
type: 'circle',
source: 'geometry',
filter: ['==', ['get', 'type'], 'pin'],
paint: {
'circle-radius': 8,
'circle-color': '#ff0000',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
});
// Pin labels
map.addLayer({
id: 'geometry-labels',
type: 'symbol',
source: 'geometry',
filter: ['==', ['get', 'type'], 'pin'],
layout: {
'text-field': ['get', 'number'],
'text-size': 12,
'text-offset': [0, 0],
'text-anchor': 'center'
},
paint: {
'text-color': '#ffffff'
}
});
// Load PNG tiles (no sandbox needed for initial display)
await loadPngTiles();
});
});
onUnmounted(() => {
if (map) {
map.remove();
map = null;
}
});
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#app {
width: 100vw;
height: 100vh;
position: relative;
}
.map-container {
width: 100%;
height: 100%;
}
.layer-controls {
position: absolute;
bottom: 20px;
left: 20px;
background: white;
padding: 15px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
font-family: Arial, sans-serif;
font-size: 14px;
}
.control-section {
margin-bottom: 10px;
}
.control-section:last-child {
margin-bottom: 0;
}
.control-section label {
display: block;
margin: 5px 0;
cursor: pointer;
}
.control-section label:first-child {
font-weight: bold;
margin-bottom: 8px;
cursor: default;
}
.control-section .section-header {
font-weight: bold;
margin-bottom: 8px;
user-select: none;
}
.subsection {
margin-left: 10px;
padding-left: 10px;
border-left: 2px solid #ddd;
}
.hide-all-btn {
width: 100%;
padding: 6px 10px;
margin: 8px 0;
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 3px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.hide-all-btn:hover {
background: #e8e8e8;
border-color: #999;
}
.site-control {
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px 0;
}
.site-control label {
flex: 1;
margin: 0 !important;
font-weight: normal !important;
}
.jump-btn {
padding: 4px 8px;
background: #4A9EFF;
color: white;
border: none;
border-radius: 3px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
margin-left: 8px;
}
.jump-btn:hover {
background: #2E8FE3;
}
.jump-btn:active {
transform: scale(0.95);
}
.control-section input[type="radio"],
.control-section input[type="checkbox"] {
margin-right: 8px;
cursor: pointer;
}
.sandbox-btn {
width: 100%;
padding: 10px;
margin-top: 10px;
background: #4A9EFF;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.sandbox-btn:hover {
background: #2E8FE3;
}
.sandbox-btn:active {
transform: scale(0.98);
}
.slider-control {
margin-top: 10px;
margin-left: 20px;
}
.slider-control label {
font-weight: normal !important;
margin-bottom: 5px !important;
}
.opacity-slider {
width: 100%;
cursor: pointer;
}
.geometry-toolbar {
position: absolute;
top: 20px;
right: 20px;
background: white;
padding: 10px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
display: flex;
flex-direction: column;
gap: 8px;
}
.tool-btn {
padding: 10px 15px;
background: white;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.tool-btn:hover {
background: #f5f5f5;
border-color: #999;
}
.tool-btn.active {
background: #4A9EFF;
color: white;
border-color: #4A9EFF;
}
.tool-btn.danger {
color: #d32f2f;
}
.tool-btn.danger:hover {
background: #ffebee;
border-color: #d32f2f;
}
.context-menu {
position: absolute;
background: white;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0,0,0,0.4);
z-index: 1000;
min-width: 200px;
overflow: hidden;
}
.context-menu-header {
padding: 10px 12px;
background: #f5f5f5;
font-size: 12px;
font-family: monospace;
border-bottom: 1px solid #ddd;
color: #666;
}
.context-menu-header .tile-name {
display: block;
margin-top: 4px;
font-size: 11px;
color: #999;
}
.context-menu-item {
display: block;
width: 100%;
padding: 10px 12px;
background: white;
border: none;
text-align: left;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.context-menu-item:hover {
background: #f0f0f0;
}
.popup {
position: absolute;
background: white;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0,0,0,0.4);
z-index: 1000;
min-width: 180px;
max-width: 350px;
overflow: hidden;
}
.popup-content {
padding: 12px;
font-size: 14px;
}
.popup-content strong {
display: block;
margin-bottom: 8px;
color: #333;
}
.popup-content div {
margin: 4px 0;
font-size: 13px;
color: #666;
}
.site-popup {
max-width: 320px;
}
.site-type {
font-size: 11px !important;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #999 !important;
margin-bottom: 8px !important;
}
.site-description {
line-height: 1.5;
color: #333 !important;
font-size: 13px !important;
}
.popup-btn {
display: block;
width: 100%;
padding: 8px;
margin-top: 10px;
background: white;
border: 1px solid #ddd;
border-radius: 3px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.popup-btn:hover {
background: #f5f5f5;
}
.popup-btn.danger {
color: #d32f2f;
border-color: #d32f2f;
}
.popup-btn.danger:hover {
background: #ffebee;
}
.popup-close {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background: none;
border: none;
font-size: 20px;
line-height: 1;
cursor: pointer;
color: #999;
transition: color 0.2s;
}
.popup-close:hover {
color: #333;
}
</style>