Compare commits
2 Commits
d97e26d881
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| dbbf69566e | |||
| 11bdb7009a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,6 +33,7 @@ erl_crash.dump
|
|||||||
|
|
||||||
# Ignore assets that are produced by build tools.
|
# Ignore assets that are produced by build tools.
|
||||||
/priv/static/assets/
|
/priv/static/assets/
|
||||||
|
/priv/static/chunks/
|
||||||
/priv/static/*.js
|
/priv/static/*.js
|
||||||
/priv/static/*.html
|
/priv/static/*.html
|
||||||
/priv/static/*.css
|
/priv/static/*.css
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ defmodule MoundHuntersWeb.Router do
|
|||||||
if File.exists?(index_path) do
|
if File.exists?(index_path) do
|
||||||
conn
|
conn
|
||||||
|> put_resp_header("content-type", "text/html; charset=utf-8")
|
|> put_resp_header("content-type", "text/html; charset=utf-8")
|
||||||
|> put_resp_header("cache-control", "no-cache")
|
|> put_resp_header("cache-control", "no-cache, must-revalidate")
|
||||||
|> send_file(200, index_path)
|
|> send_file(200, index_path)
|
||||||
else
|
else
|
||||||
send_resp(conn, 404, "Not found")
|
send_resp(conn, 404, "Not found")
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
||||||
import { ref, onMounted, onUnmounted } from 'vue';
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
@@ -111,13 +112,18 @@ import { KNOWN_SITES } from './data/historicSites.js';
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// UTILITIES
|
// UTILITIES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
import { calculateDistance, calculateBearing, extendRay } from './utils/geometry.js';
|
import { calculateDistance, calculateBearing, extendRay, generateRayCoordinates } from './utils/geometry.js';
|
||||||
import { webMercatorToLonLat } from './utils/coordinates.js';
|
import { webMercatorToLonLat } from './utils/coordinates.js';
|
||||||
import { useTilesStore } from './stores/tiles.js';
|
import { useTilesStore } from './stores/tiles.js';
|
||||||
|
|
||||||
// For generating the pre-baked tiles:
|
// For generating the pre-baked tiles:
|
||||||
// import { batchRenderTiles } from './utils/batch-renderer.js';
|
import { batchRenderTiles } from './utils/batch-renderer.js';
|
||||||
// batchRenderTiles(sandboxRef, tileCache, tileNames) {
|
let tiles = [];
|
||||||
|
var batchRenderingActivated = false
|
||||||
|
if (tiles.length > 0) {
|
||||||
|
batchRenderingActivated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CONSTANTS
|
// CONSTANTS
|
||||||
@@ -258,11 +264,11 @@ async function loadTileImages(tileId, tileMetadata, imageUrl = null) {
|
|||||||
const isOverwriting = imageUrl && tilesStore.areImagesOnMap(tileId);
|
const isOverwriting = imageUrl && tilesStore.areImagesOnMap(tileId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Determine which format to load (prefer PNG over JPG)
|
// Determine which format to load
|
||||||
if (!imageUrl) {
|
if (!imageUrl) {
|
||||||
imageUrl = tileMetadata.png_available
|
imageUrl = tileMetadata.jpg_available
|
||||||
? tilesStore.getImageUrl(tileId, 'png')
|
? tilesStore.getImageUrl(tileId, 'jpg')
|
||||||
: tilesStore.getImageUrl(tileId, 'jpg');
|
: tilesStore.getImageUrl(tileId, 'png');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove old source/layer if overwriting
|
// Remove old source/layer if overwriting
|
||||||
@@ -530,6 +536,14 @@ function onRenderComplete(result) {
|
|||||||
function onRenderError(err) {
|
function onRenderError(err) {
|
||||||
console.error('Renderer error:', err);
|
console.error('Renderer error:', err);
|
||||||
}
|
}
|
||||||
|
// ============================================================================
|
||||||
|
// BIBLIOGRAPHY
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function showBibliography(citationKey) {
|
||||||
|
highlightedCitation.value = citationKey;
|
||||||
|
bibliographyVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// GEOMETRY DRAWING
|
// GEOMETRY DRAWING
|
||||||
@@ -568,13 +582,29 @@ function setDrawMode(mode) {
|
|||||||
|
|
||||||
function completeDrawing() {
|
function completeDrawing() {
|
||||||
const [pt1, pt2] = drawPoints.value;
|
const [pt1, pt2] = drawPoints.value;
|
||||||
const coords = drawMode.value === 'ray'
|
|
||||||
? [pt1, extendRay(pt1[0], pt1[1], pt2[0], pt2[1], map.getBounds())]
|
let coords;
|
||||||
: [pt1, pt2];
|
let properties = {
|
||||||
|
type: drawMode.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (drawMode.value === 'ray') {
|
||||||
|
// Store the control points
|
||||||
|
properties.controlPoints = [pt1, pt2];
|
||||||
|
|
||||||
|
// Generate densely-sampled ray
|
||||||
|
coords = generateRayCoordinates(pt1[0], pt1[1], pt2[0], pt2[1], 1000);
|
||||||
|
} else {
|
||||||
|
// Regular line - just two points
|
||||||
|
coords = [pt1, pt2];
|
||||||
|
}
|
||||||
|
|
||||||
const distance = calculateDistance(coords[0][0], coords[0][1], coords[1][0], coords[1][1]);
|
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 bearing = calculateBearing(coords[0][0], coords[0][1], coords[1][0], coords[1][1]);
|
||||||
|
|
||||||
|
properties.length = distance;
|
||||||
|
properties.bearing = bearing;
|
||||||
|
|
||||||
const feature = {
|
const feature = {
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
id: `feature-${nextFeatureId.value++}`,
|
id: `feature-${nextFeatureId.value++}`,
|
||||||
@@ -582,11 +612,7 @@ function completeDrawing() {
|
|||||||
type: 'LineString',
|
type: 'LineString',
|
||||||
coordinates: coords
|
coordinates: coords
|
||||||
},
|
},
|
||||||
properties: {
|
properties: properties
|
||||||
type: drawMode.value,
|
|
||||||
length: distance,
|
|
||||||
bearing: bearing
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
geometryFeatures.value.features.push(feature);
|
geometryFeatures.value.features.push(feature);
|
||||||
@@ -642,15 +668,6 @@ function clearAllGeometry() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// BIBLIOGRAPHY
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function showBibliography(citationKey) {
|
|
||||||
highlightedCitation.value = citationKey;
|
|
||||||
bibliographyVisible.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// GEOMETRY LAYER UPDATES
|
// GEOMETRY LAYER UPDATES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -996,6 +1013,16 @@ onMounted(() => {
|
|||||||
|
|
||||||
// Load initial tiles for known sites
|
// Load initial tiles for known sites
|
||||||
await loadInitialTiles();
|
await loadInitialTiles();
|
||||||
|
|
||||||
|
if (batchRenderingActivated){
|
||||||
|
openSandbox();
|
||||||
|
let promises = tiles.flatMap(tile_id => [
|
||||||
|
tilesStore.fetchMoundData(tile_id, parseMoundBuffer),
|
||||||
|
tilesStore.fetchMetadataById(tile_id, parseMoundBuffer)
|
||||||
|
]);
|
||||||
|
await Promise.all(promises);
|
||||||
|
batchRenderTiles(sandboxRef.value, tilesStore, tiles)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
358
ui/src/components/AboutModal.vue
Normal file
358
ui/src/components/AboutModal.vue
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal">
|
||||||
|
<div v-if="isOpen" class="modal-backdrop" @click="closeModal">
|
||||||
|
<div class="modal-content" @click.stop>
|
||||||
|
<button class="close-button" @click="closeModal" aria-label="Close">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<h2>About This Project</h2>
|
||||||
|
|
||||||
|
<section class="intro">
|
||||||
|
<p>
|
||||||
|
Around 2,000 years ago, the Hopewell culture built a 60-mile ceremonial road
|
||||||
|
connecting Newark to Chillicothe, Ohio. Most of it has been destroyed by farming,
|
||||||
|
but tiny remnants might still be hiding in the landscape—parallel earthen walls
|
||||||
|
just 50cm tall, visible only as subtle shadows in the right light.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
There's way too much terrain for archaeologists to analyze alone. That's where
|
||||||
|
you come in! This tool lets you explore high-resolution lidar data and help
|
||||||
|
search for lost sections of the Great Hopewell Road.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="video-section">
|
||||||
|
<h3>Learn More About the Road</h3>
|
||||||
|
<div class="video-container">
|
||||||
|
<iframe
|
||||||
|
src="https://www.youtube.com/embed/Ltu2hJwqId8"
|
||||||
|
title="The Great Hopewell Road"
|
||||||
|
frameborder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="instructions">
|
||||||
|
<h3>How to Use This Tool</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Start exploring:</strong> Check out the known archaeological sites on the map.
|
||||||
|
Click the pin icon next to each site name to fly there and see the crisp lidar imagery.
|
||||||
|
Click on any of the pins on the map to learn more about the cite, and follow up on
|
||||||
|
the cited references to dive further down the rabbit hole.
|
||||||
|
Use the sidebar controls to customize what you see.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Hunt for the road:</strong> Right-click anywhere on the map (within Ohio) to
|
||||||
|
request a tile. If someone's already requested it, you can load the interactive data
|
||||||
|
instantly. Otherwise, you'll wait a bit for the processing pipeline.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Adjust the lighting:</strong> Once a tile loads, the shading sandbox opens.
|
||||||
|
Change the lighting direction and exaggerate the terrain to increase contrast—this
|
||||||
|
makes subtle features pop out. Click "Render Tile" to add it to the map.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Mark your finds:</strong> Found something interesting? Drop a pin using the
|
||||||
|
geometry tools in the top right. Then share it on
|
||||||
|
<a href="http://discord.gg/miniminuteclan" target="_blank" rel="noopener noreferrer">
|
||||||
|
Discord
|
||||||
|
</a>
|
||||||
|
with coordinates and a screenshot!
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="about-author">
|
||||||
|
<h3>About me</h3>
|
||||||
|
<p>
|
||||||
|
I'm Mark Kalsbeek, a web developer and researcher from the Netherlands.
|
||||||
|
I was inspired to build this after watching Milo's video above. If you want to
|
||||||
|
reach me, @ me on
|
||||||
|
<a href="http://discord.gg/miniminuteclan" target="_blank" rel="noopener noreferrer">
|
||||||
|
Milo's Discord
|
||||||
|
</a>:
|
||||||
|
<span v-if="!usernameRevealed" class="username-reveal" @click="revealUsername">
|
||||||
|
reveal
|
||||||
|
</span>
|
||||||
|
<span v-else class="username">{{ username }}</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="thanks">
|
||||||
|
<h3>Thanks</h3>
|
||||||
|
<p>
|
||||||
|
Special thanks to <a href="https://gis1.oit.ohio.gov/geodatadownload/" target="_blank" rel="noopener noreferrer">Ohio OGRIP</a>
|
||||||
|
for making high-quality lidar data freely available with an easy-to-reverse API.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Map tiles from <a href="https://www.openstreetmap.org/" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors
|
||||||
|
and satellite imagery from <a href="https://www.esri.com/" target="_blank" rel="noopener noreferrer">Esri</a>.
|
||||||
|
</p>
|
||||||
|
<p class="tech-stack">
|
||||||
|
Built with: Elixir (plug_cowboy, jason, geo, logger_file_backend, httpoison, mime),
|
||||||
|
Python (laspy, scipy, numpy, pyproj),
|
||||||
|
Deno (Vue, Vite, Three.js, MapLibre GL, Pinia),
|
||||||
|
and of course Claude for infinite amounts of grunt work.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, onMounted } from 'vue';
|
||||||
|
|
||||||
|
const MODAL_VERSION = '1.0';
|
||||||
|
const STORAGE_KEY = 'aboutModalVersion';
|
||||||
|
const OBFUSCATED_USERNAME = '404d61726b6b313136';
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const usernameRevealed = ref(false);
|
||||||
|
const username = ref('');
|
||||||
|
const seenRecentVersion = ref(true);
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const revealUsername = () => {
|
||||||
|
username.value = OBFUSCATED_USERNAME.match(/.{2}/g)
|
||||||
|
.map(hex => String.fromCharCode(parseInt(hex, 16)))
|
||||||
|
.join('');
|
||||||
|
usernameRevealed.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
isOpen.value = newVal;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(isOpen, (newVal) => {
|
||||||
|
emit('update:modelValue', newVal);
|
||||||
|
if (!newVal) {
|
||||||
|
// Mark as seen when closed
|
||||||
|
localStorage.setItem(STORAGE_KEY, MODAL_VERSION);
|
||||||
|
seenRecentVersion.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if should show on mount
|
||||||
|
onMounted(() => {
|
||||||
|
const seenVersion = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (seenVersion !== MODAL_VERSION) {
|
||||||
|
seenRecentVersion.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expose methods for parent component
|
||||||
|
defineExpose({
|
||||||
|
open: () => { isOpen.value = true; },
|
||||||
|
seenRecentVersion,
|
||||||
|
close: closeModal
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
position: relative;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
max-width: 60vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.modal-content {
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
padding: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color 0.2s;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button:hover {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 2rem;
|
||||||
|
padding-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro p:first-child {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-section {
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 56.25%; /* 16:9 aspect ratio */
|
||||||
|
margin: 1rem 0;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions p {
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions strong {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #0066cc;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-reveal {
|
||||||
|
display: inline-block;
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-reveal:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-family: monospace;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-stack {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal transitions */
|
||||||
|
.modal-enter-active,
|
||||||
|
.modal-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from,
|
||||||
|
.modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-active .modal-content,
|
||||||
|
.modal-leave-active .modal-content {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from .modal-content,
|
||||||
|
.modal-leave-to .modal-content {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,14 +15,21 @@
|
|||||||
<button @click="$emit('delete', feature.id)" class="popup-btn danger">Delete</button>
|
<button @click="$emit('delete', feature.id)" class="popup-btn danger">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LINE or RAY -->
|
<!-- LINE -->
|
||||||
<div v-else-if="type === 'line' || type === 'ray'">
|
<div v-else-if="type === 'line'">
|
||||||
<strong>{{ type === 'line' ? 'Line' : 'Ray' }}</strong>
|
<strong>Line</strong>
|
||||||
<div>Length: {{ formatDistance(feature.properties.length, imperialUnits) }}</div>
|
<div>Length: {{ formatDistance(feature.properties.length, imperialUnits) }}</div>
|
||||||
<div>Bearing: {{ formatBearing(feature.properties.bearing) }}</div>
|
<div>Bearing: {{ formatBearing(feature.properties.bearing) }}</div>
|
||||||
<button @click="$emit('delete', feature.id)" class="popup-btn danger">Delete</button>
|
<button @click="$emit('delete', feature.id)" class="popup-btn danger">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- RAY -->
|
||||||
|
<div v-else-if="type === 'line' || type === 'ray'">
|
||||||
|
<strong>Ray</strong>
|
||||||
|
<div>Bearing: {{ formatBearing(feature.properties.bearing) }}</div>
|
||||||
|
<button @click="$emit('delete', feature.id)" class="popup-btn danger">Delete</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- HISTORIC SITE -->
|
<!-- HISTORIC SITE -->
|
||||||
<div v-else-if="type === 'site'" class="site-popup">
|
<div v-else-if="type === 'site'" class="site-popup">
|
||||||
<strong>{{ feature.properties.name }}</strong>
|
<strong>{{ feature.properties.name }}</strong>
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="layer-controls">
|
<div class="layer-controls">
|
||||||
|
<button
|
||||||
|
class="about-button"
|
||||||
|
:class="{ 'pulse-highlight': !aboutRef?.seenRecentVersion }"
|
||||||
|
@click="aboutRef.open"
|
||||||
|
>
|
||||||
|
About this App
|
||||||
|
</button>
|
||||||
|
<AboutModal ref="aboutRef"/>
|
||||||
<!-- ============================================= -->
|
<!-- ============================================= -->
|
||||||
<!-- BASE MAP SELECTION -->
|
<!-- BASE MAP SELECTION -->
|
||||||
<!-- ============================================= -->
|
<!-- ============================================= -->
|
||||||
@@ -106,29 +114,13 @@
|
|||||||
Imperial Units
|
Imperial Units
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============================================= -->
|
|
||||||
<!-- SANDBOX BUTTON -->
|
|
||||||
<!-- ============================================= -->
|
|
||||||
<div class="control-section">
|
|
||||||
<button class="sandbox-btn" @click="$emit('openSandbox')">
|
|
||||||
Open Shading Sandbox
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ============================================= -->
|
|
||||||
<!-- TILE REQUEST NOTIFICATIONS -->
|
|
||||||
<!-- ============================================= -->
|
|
||||||
<TileRequestNotification
|
|
||||||
:requests="tileRequests"
|
|
||||||
@dismiss="(id) => $emit('dismissRequest', id)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
import { KNOWN_SITES } from '../data/historicSites.js';
|
import { KNOWN_SITES } from '../data/historicSites.js';
|
||||||
import TileRequestNotification from './TileRequestNotification.vue';
|
import AboutModal from './AboutModal.vue';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// INTERFACE
|
// INTERFACE
|
||||||
@@ -173,6 +165,8 @@ defineProps({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const aboutRef = ref(null);
|
||||||
|
|
||||||
defineEmits([
|
defineEmits([
|
||||||
'update:baseLayer',
|
'update:baseLayer',
|
||||||
'update:historicMarkersExpanded',
|
'update:historicMarkersExpanded',
|
||||||
@@ -212,6 +206,42 @@ const sites = KNOWN_SITES;
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================= */
|
||||||
|
/* About button */
|
||||||
|
/* ============================================= */
|
||||||
|
|
||||||
|
.about-button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(180deg, #4B91F7 0%, #367AF6 100%);
|
||||||
|
background-origin: border-box;
|
||||||
|
box-shadow: 0px 0.5px 1.5px rgba(54, 122, 246, 0.25), inset 0px 0.8px 0px -0.25px rgba(255, 255, 255, 0.2);
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-button:focus {
|
||||||
|
box-shadow: inset 0px 0.8px 0px -0.25px rgba(255, 255, 255, 0.2), 0px 0.5px 1.5px rgba(54, 122, 246, 0.25), 0px 0px 0px 3.5px rgba(58, 108, 217, 0.5);
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 20px rgba(59, 130, 246, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-highlight {
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================= */
|
/* ============================================= */
|
||||||
/* CONTROL SECTIONS */
|
/* CONTROL SECTIONS */
|
||||||
/* ============================================= */
|
/* ============================================= */
|
||||||
|
|||||||
@@ -240,7 +240,6 @@ const initThreeJS = () => {
|
|||||||
|
|
||||||
// Animation loop control
|
// Animation loop control
|
||||||
const startAnimation = () => {
|
const startAnimation = () => {
|
||||||
console.log('starting animation')
|
|
||||||
if (animationId) return; // Already running
|
if (animationId) return; // Already running
|
||||||
|
|
||||||
const animate = () => {
|
const animate = () => {
|
||||||
@@ -258,7 +257,6 @@ const startAnimation = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pauseAnimation = () => {
|
const pauseAnimation = () => {
|
||||||
console.log('pausing animation')
|
|
||||||
animationPaused = true;
|
animationPaused = true;
|
||||||
if (animationId) {
|
if (animationId) {
|
||||||
cancelAnimationFrame(animationId);
|
cancelAnimationFrame(animationId);
|
||||||
@@ -267,14 +265,12 @@ const pauseAnimation = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resumeAnimation = () => {
|
const resumeAnimation = () => {
|
||||||
console.log('resuming animation')
|
|
||||||
animationPaused = false;
|
animationPaused = false;
|
||||||
startAnimation();
|
startAnimation();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render a single frame (for when animation is paused but we need to update the view)
|
// Render a single frame (for when animation is paused but we need to update the view)
|
||||||
const renderSingleFrame = () => {
|
const renderSingleFrame = () => {
|
||||||
console.log('Rendering single frame')
|
|
||||||
if (renderer && scene && camera) {
|
if (renderer && scene && camera) {
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
}
|
}
|
||||||
@@ -312,8 +308,7 @@ const handleResize = () => {
|
|||||||
// Load tile data
|
// Load tile data
|
||||||
const loadTileData = (tileData, newTileId) => {
|
const loadTileData = (tileData, newTileId) => {
|
||||||
if (!scene) {
|
if (!scene) {
|
||||||
console.error('Three.js not initialized');
|
initThreeJS();
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
tileId.value = newTileId;
|
tileId.value = newTileId;
|
||||||
|
|
||||||
@@ -547,8 +542,7 @@ const renderTile = async () => {
|
|||||||
// Designed to be called repeatedly without setup/teardown overhead
|
// Designed to be called repeatedly without setup/teardown overhead
|
||||||
const renderTileWithSettings = async (tileData, renderSettings, resolution = 1024) => {
|
const renderTileWithSettings = async (tileData, renderSettings, resolution = 1024) => {
|
||||||
if (!renderer || !scene || !camera) {
|
if (!renderer || !scene || !camera) {
|
||||||
console.error('Renderer not initialized');
|
initThreeJS();
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="activeRequests.length > 0" class="tile-notifications">
|
|
||||||
<!-- ============================================= -->
|
|
||||||
<!-- HEADER -->
|
|
||||||
<!-- ============================================= -->
|
|
||||||
<div class="notifications-header">
|
|
||||||
Tile Requests
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ============================================= -->
|
|
||||||
<!-- ACTIVE REQUESTS -->
|
|
||||||
<!-- ============================================= -->
|
|
||||||
<div
|
|
||||||
v-for="request in activeRequests"
|
|
||||||
:key="request.id"
|
|
||||||
:class="['notification-item', `status-${request.status}`]"
|
|
||||||
>
|
|
||||||
<div class="notification-content">
|
|
||||||
<div class="notification-location">
|
|
||||||
{{ formatLocation(request.lat, request.lng) }}
|
|
||||||
</div>
|
|
||||||
<div class="notification-status">
|
|
||||||
<span class="status-icon">{{ getStatusIcon(request.status) }}</span>
|
|
||||||
<span class="status-text">{{ getStatusText(request.status) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="request.message" class="notification-message">
|
|
||||||
{{ request.message }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Close button for completed/failed requests -->
|
|
||||||
<button
|
|
||||||
v-if="request.status === 'ready' || request.status === 'error'"
|
|
||||||
@click="$emit('dismiss', request.id)"
|
|
||||||
class="dismiss-btn"
|
|
||||||
title="Dismiss"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// INTERFACE
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
requests: {
|
|
||||||
type: Object, // { requestId: { lat, lng, status, message, tileId } }
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
defineEmits(['dismiss']);
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// COMPUTED
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const activeRequests = computed(() => {
|
|
||||||
return Object.entries(props.requests).map(([id, data]) => ({
|
|
||||||
id,
|
|
||||||
...data
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// METHODS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function formatLocation(lat, lng) {
|
|
||||||
return `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusIcon(status) {
|
|
||||||
switch (status) {
|
|
||||||
case 'looking_up': return '🔍';
|
|
||||||
case 'found': return '✓';
|
|
||||||
case 'processing': return '⚙️';
|
|
||||||
case 'ready': return '✅';
|
|
||||||
case 'error': return '❌';
|
|
||||||
default: return '•';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusText(status) {
|
|
||||||
switch (status) {
|
|
||||||
case 'looking_up': return 'Finding tile...';
|
|
||||||
case 'found': return 'Tile found';
|
|
||||||
case 'processing': return 'Processing...';
|
|
||||||
case 'ready': return 'Ready!';
|
|
||||||
case 'error': return 'Failed';
|
|
||||||
default: return status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* ============================================= */
|
|
||||||
/* NOTIFICATIONS CONTAINER */
|
|
||||||
/* ============================================= */
|
|
||||||
|
|
||||||
.tile-notifications {
|
|
||||||
margin-top: 15px;
|
|
||||||
padding-top: 15px;
|
|
||||||
border-top: 2px solid #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================= */
|
|
||||||
/* HEADER */
|
|
||||||
/* ============================================= */
|
|
||||||
|
|
||||||
.notifications-header {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================= */
|
|
||||||
/* NOTIFICATION ITEMS */
|
|
||||||
/* ============================================= */
|
|
||||||
|
|
||||||
.notification-item {
|
|
||||||
background: #f9f9f9;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 10px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border-left: 3px solid #ccc;
|
|
||||||
position: relative;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item.status-looking_up {
|
|
||||||
border-left-color: #4A9EFF;
|
|
||||||
background: #f0f7ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item.status-found {
|
|
||||||
border-left-color: #4CAF50;
|
|
||||||
background: #f1f8f4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item.status-processing {
|
|
||||||
border-left-color: #FF9800;
|
|
||||||
background: #fff8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item.status-ready {
|
|
||||||
border-left-color: #4CAF50;
|
|
||||||
background: #f1f8f4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item.status-error {
|
|
||||||
border-left-color: #f44336;
|
|
||||||
background: #fff0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================= */
|
|
||||||
/* NOTIFICATION CONTENT */
|
|
||||||
/* ============================================= */
|
|
||||||
|
|
||||||
.notification-content {
|
|
||||||
padding-right: 24px; /* Space for dismiss button */
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-location {
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #999;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-message {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
margin-top: 4px;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================= */
|
|
||||||
/* DISMISS BUTTON */
|
|
||||||
/* ============================================= */
|
|
||||||
|
|
||||||
.dismiss-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
right: 8px;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #999;
|
|
||||||
transition: color 0.2s;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dismiss-btn:hover {
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -46,15 +46,15 @@ export const KNOWN_SITES = [
|
|||||||
"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}.",
|
"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",
|
"type": "earthwork",
|
||||||
"tiles": ["BS19870742", 'BS19870743', 'BS19880743'],
|
"tiles": ["BS19870742", 'BS19870743', 'BS19880743'],
|
||||||
"overlay" : [
|
// "overlay" : [
|
||||||
{
|
// {
|
||||||
"type": 'line',
|
// "type": 'line',
|
||||||
"coordinates": [
|
// "coordinates": [
|
||||||
[-82.459197, 40.027871],
|
// [-82.459197, 40.027871],
|
||||||
[-82.458565, 40.028731]
|
// [-82.458565, 40.028731]
|
||||||
]
|
// ]
|
||||||
}
|
// }
|
||||||
]
|
// ]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Van Voorhis Walls",
|
"name": "Van Voorhis Walls",
|
||||||
@@ -91,6 +91,13 @@ export const KNOWN_SITES = [
|
|||||||
"type": "earthwork",
|
"type": "earthwork",
|
||||||
"tiles": ['BS17630452', 'BS17630451', 'BS17650451', 'BS17620451', 'BS17620450']
|
"tiles": ['BS17630452', 'BS17630451', 'BS17650451', 'BS17620451', 'BS17620450']
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Southernmost identfied Section",
|
||||||
|
"description":"Southernmost identfied GHR Section according to \\cite{lepper_2024}",
|
||||||
|
"coordinates": [[-82.52056,39.95528]],
|
||||||
|
"type": 'road_confirmed',
|
||||||
|
"tiles": ['BS19620711',"BS19610711"]
|
||||||
|
},
|
||||||
// There is something in this area, but I can't confirm it's the 'high banks works'
|
// 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"
|
// 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
|
// A historical map puts it near the Scioto river, but that's on the other side of columbus
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Batch Renderer for Hopewell Lidar Tiles
|
* Batch Renderer for Hopewell Lidar Tiles
|
||||||
*
|
*
|
||||||
* Usage in dev console with loaded app:
|
* Usage:
|
||||||
* import { batchRenderTiles } from './batch-renderer.js';
|
* import { batchRenderTiles } from './batch-renderer.js';
|
||||||
* const app = document.querySelector('#app').__vue_app__;
|
|
||||||
* const sandboxRef = app._instance.refs.sandboxRef;
|
|
||||||
* const tileCache = app._instance.data.tileCache;
|
|
||||||
*
|
*
|
||||||
* await batchRenderTiles(sandboxRef, tileCache, tileNames);
|
* // Get refs from your app
|
||||||
|
* const tilesStore = useTilesStore();
|
||||||
|
* const sandboxRef = ref(null); // ref to ShadingSandbox component
|
||||||
|
*
|
||||||
|
* // Render tiles
|
||||||
|
* const results = await batchRenderTiles(
|
||||||
|
* sandboxRef.value,
|
||||||
|
* tilesStore,
|
||||||
|
* ['tile-id-1', 'tile-id-2', 'tile-id-3'],
|
||||||
|
* { renderQuality: 1024 }
|
||||||
|
* );
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,62 +30,53 @@ function downloadDataURL(dataURL, filename) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download text content as a file
|
* Batch render tiles using offscreen ShadingSandbox
|
||||||
*/
|
|
||||||
function downloadText(text, filename) {
|
|
||||||
const blob = new Blob([text], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batch render tiles using already-loaded app
|
|
||||||
*
|
*
|
||||||
* @param {Object} sandboxRef - Vue ref to ShadingSandbox component
|
* @param {Object} sandboxComponent - Vue component instance (sandboxRef.value)
|
||||||
* @param {Object} tileCache - Cache of loaded tile data
|
* @param {Object} tilesStore - Pinia tiles store instance
|
||||||
* @param {string[]} tileNames - Array of tile names to render
|
* @param {string[]} tileIds - Array of tile IDs to render (must be pre-loaded in store)
|
||||||
* @param {Object} options - Options
|
* @param {Object} options - Options
|
||||||
* @param {Object} options.renderSettings - Override render settings
|
* @param {number} options.renderQuality - Render quality in pixels (default: 1024)
|
||||||
* @param {number} options.renderQuality - Render quality (default: 1024)
|
|
||||||
*
|
*
|
||||||
* @returns {Promise<Object[]>} Array of results
|
* @returns {Promise<Object[]>} Array of results
|
||||||
*/
|
*/
|
||||||
export async function batchRenderTiles(sandboxRef, tileCache, tileNames, options = {}) {
|
export async function batchRenderTiles(sandboxComponent, tilesStore, tileIds, options = {}) {
|
||||||
const {
|
const {
|
||||||
renderSettings = null, // Use sandbox defaults if null
|
|
||||||
renderQuality = 1024
|
renderQuality = 1024
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
console.log(`[BatchRenderer] Starting batch render of ${tileNames.length} tiles`);
|
console.log(`[BatchRenderer] Starting batch render of ${tileIds.length} tiles`);
|
||||||
console.log(`[BatchRenderer] Quality: ${renderQuality}px`);
|
console.log(`[BatchRenderer] Quality: ${renderQuality}px`);
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
sandboxComponent.offscreen = true;
|
||||||
|
|
||||||
for (let i = 0; i < tileNames.length; i++) {
|
for (let i = 0; i < tileIds.length; i++) {
|
||||||
const tileName = tileNames[i];
|
const tileId = tileIds[i];
|
||||||
const current = i + 1;
|
const current = i + 1;
|
||||||
const total = tileNames.length;
|
const total = tileIds.length;
|
||||||
|
|
||||||
console.log(`[BatchRenderer] [${current}/${total}] Processing ${tileName}...`);
|
console.log(`[BatchRenderer] [${current}/${total}] Processing ${tileId}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tileData = tileCache[tileName];
|
// Get tile data from store
|
||||||
if (!tileData) {
|
const metadata = tilesStore.getMetadata(tileId);
|
||||||
throw new Error(`Tile ${tileName} not found in cache`);
|
const moundData = tilesStore.getMoundData(tileId);
|
||||||
|
|
||||||
|
if (!metadata) {
|
||||||
|
throw new Error(`Metadata not found in store for ${tileId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render
|
if (!moundData) {
|
||||||
console.log(`[BatchRenderer] Rendering...`);
|
throw new Error(`Mound data not found in store for ${tileId}`);
|
||||||
const renderResult = await sandboxRef.renderTileWithSettings(
|
}
|
||||||
tileData,
|
|
||||||
renderSettings || sandboxRef.getSettings(),
|
// Render with current settings
|
||||||
|
console.log(`[BatchRenderer] Rendering at ${renderQuality}px...`);
|
||||||
|
const renderResult = await sandboxComponent.renderTileWithSettings(
|
||||||
|
moundData,
|
||||||
|
sandboxComponent.getSettings(),
|
||||||
renderQuality
|
renderQuality
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -88,37 +86,21 @@ export async function batchRenderTiles(sandboxRef, tileCache, tileNames, options
|
|||||||
|
|
||||||
console.log(`[BatchRenderer] Rendered in ${renderResult.renderTime}ms`);
|
console.log(`[BatchRenderer] Rendered in ${renderResult.renderTime}ms`);
|
||||||
|
|
||||||
// Generate metadata
|
// Download PNG
|
||||||
const metadata = {
|
downloadDataURL(renderResult.dataURL, `${tileId}.png`);
|
||||||
tileName,
|
|
||||||
bounds: tileData.bounds,
|
|
||||||
renderSettings: renderSettings || sandboxRef.getSettings(),
|
|
||||||
renderQuality,
|
|
||||||
pointCount: tileData.pointCount,
|
|
||||||
triangleCount: tileData.triangleCount,
|
|
||||||
renderedAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Download
|
|
||||||
downloadDataURL(renderResult.dataURL, `${tileName}.png`);
|
|
||||||
downloadText(JSON.stringify(metadata, null, 2), `${tileName}.json`);
|
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
tileName,
|
tileId,
|
||||||
metadata,
|
|
||||||
renderTime: renderResult.renderTime,
|
renderTime: renderResult.renderTime,
|
||||||
success: true
|
success: true
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[BatchRenderer] ✓ Complete`);
|
console.log(`[BatchRenderer] ✓ Complete`);
|
||||||
|
|
||||||
// Small delay
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[BatchRenderer] ✗ Failed: ${err.message}`);
|
console.error(`[BatchRenderer] ✗ Failed: ${err.message}`);
|
||||||
results.push({
|
results.push({
|
||||||
tileName,
|
tileId,
|
||||||
success: false,
|
success: false,
|
||||||
error: err.message
|
error: err.message
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function formatDistance(meters, useImperial = false) {
|
|||||||
* Format bearing angle
|
* Format bearing angle
|
||||||
*/
|
*/
|
||||||
export function formatBearing(degrees) {
|
export function formatBearing(degrees) {
|
||||||
return `${degrees.toFixed(1)}°`;
|
return `${degrees.toFixed(2)}°`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function calculateBearing(lng1, lat1, lng2, lat2) {
|
|||||||
* Extend a line from point1 through point2 to a far distance (100km)
|
* Extend a line from point1 through point2 to a far distance (100km)
|
||||||
* Used for ray drawing
|
* Used for ray drawing
|
||||||
*/
|
*/
|
||||||
export function extendRay(lng1, lat1, lng2, lat2, bounds) {
|
export function extendRay(lng1, lat1, lng2, lat2) {
|
||||||
const bearing = calculateBearing(lng1, lat1, lng2, lat2);
|
const bearing = calculateBearing(lng1, lat1, lng2, lat2);
|
||||||
const bearingRad = bearing * Math.PI / 180;
|
const bearingRad = bearing * Math.PI / 180;
|
||||||
|
|
||||||
@@ -57,3 +57,26 @@ export function extendRay(lng1, lat1, lng2, lat2, bounds) {
|
|||||||
|
|
||||||
return [labmda_2 * 180 / Math.PI, phi_2 * 180 / Math.PI];
|
return [labmda_2 * 180 / Math.PI, phi_2 * 180 / Math.PI];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a densely-sampled ray from pt1 through pt2
|
||||||
|
* Creates many intermediate points so the line appears straight in Mercator projection
|
||||||
|
*/
|
||||||
|
export function generateRayCoordinates(lng1, lat1, lng2, lat2, numSegments = 1000) {
|
||||||
|
const dLng = lng2 - lng1;
|
||||||
|
const dLat = lat2 - lat1;
|
||||||
|
|
||||||
|
// Extend the ray far beyond point 2
|
||||||
|
const extensionFactor = 100;
|
||||||
|
|
||||||
|
const coords = [];
|
||||||
|
for (let i = 0; i <= numSegments; i++) {
|
||||||
|
const t = (i / numSegments) * extensionFactor;
|
||||||
|
coords.push([
|
||||||
|
lng1 + dLng * t,
|
||||||
|
lat1 + dLat * t
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return coords;
|
||||||
|
}
|
||||||
@@ -20,9 +20,14 @@ export default defineConfig({
|
|||||||
main: resolve(__dirname, 'index.html'),
|
main: resolve(__dirname, 'index.html'),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: 'app.js',
|
entryFileNames: 'app-[hash].js',
|
||||||
chunkFileNames: 'chunks/[name].js',
|
chunkFileNames: 'chunks/[name]-[hash].js',
|
||||||
assetFileNames: 'assets/[name].[ext]'
|
assetFileNames: 'assets/[name]-[hash].[ext]',
|
||||||
|
manualChunks: {
|
||||||
|
'vendor': ['vue', 'pinia'],
|
||||||
|
'three': ['three'],
|
||||||
|
'maplibre': ['maplibre-gl']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user