1786 lines
54 KiB
Vue
1786 lines
54 KiB
Vue
<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> |