Compare commits

..

2 Commits

Author SHA1 Message Date
dbbf69566e update vite config and added about modal 2026-01-25 23:35:50 +01:00
11bdb7009a tweaks and fix of ray feature 2026-01-25 15:52:46 +01:00
13 changed files with 563 additions and 353 deletions

1
.gitignore vendored
View File

@@ -33,6 +33,7 @@ erl_crash.dump
# Ignore assets that are produced by build tools.
/priv/static/assets/
/priv/static/chunks/
/priv/static/*.js
/priv/static/*.html
/priv/static/*.css

View File

@@ -65,7 +65,7 @@ defmodule MoundHuntersWeb.Router do
if File.exists?(index_path) do
conn
|> 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)
else
send_resp(conn, 404, "Not found")

View File

@@ -89,6 +89,7 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
@@ -111,13 +112,18 @@ import { KNOWN_SITES } from './data/historicSites.js';
// ============================================================================
// 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 { useTilesStore } from './stores/tiles.js';
// For generating the pre-baked tiles:
// import { batchRenderTiles } from './utils/batch-renderer.js';
// batchRenderTiles(sandboxRef, tileCache, tileNames) {
import { batchRenderTiles } from './utils/batch-renderer.js';
let tiles = [];
var batchRenderingActivated = false
if (tiles.length > 0) {
batchRenderingActivated = true
}
// ============================================================================
// CONSTANTS
@@ -258,11 +264,11 @@ async function loadTileImages(tileId, tileMetadata, imageUrl = null) {
const isOverwriting = imageUrl && tilesStore.areImagesOnMap(tileId);
try {
// Determine which format to load (prefer PNG over JPG)
// Determine which format to load
if (!imageUrl) {
imageUrl = tileMetadata.png_available
? tilesStore.getImageUrl(tileId, 'png')
: tilesStore.getImageUrl(tileId, 'jpg');
imageUrl = tileMetadata.jpg_available
? tilesStore.getImageUrl(tileId, 'jpg')
: tilesStore.getImageUrl(tileId, 'png');
}
// Remove old source/layer if overwriting
@@ -530,6 +536,14 @@ function onRenderComplete(result) {
function onRenderError(err) {
console.error('Renderer error:', err);
}
// ============================================================================
// BIBLIOGRAPHY
// ============================================================================
function showBibliography(citationKey) {
highlightedCitation.value = citationKey;
bibliographyVisible.value = true;
}
// ============================================================================
// GEOMETRY DRAWING
@@ -568,13 +582,29 @@ function setDrawMode(mode) {
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];
let coords;
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 bearing = calculateBearing(coords[0][0], coords[0][1], coords[1][0], coords[1][1]);
properties.length = distance;
properties.bearing = bearing;
const feature = {
type: 'Feature',
id: `feature-${nextFeatureId.value++}`,
@@ -582,11 +612,7 @@ function completeDrawing() {
type: 'LineString',
coordinates: coords
},
properties: {
type: drawMode.value,
length: distance,
bearing: bearing
}
properties: properties
};
geometryFeatures.value.features.push(feature);
@@ -642,15 +668,6 @@ function clearAllGeometry() {
}
}
// ============================================================================
// BIBLIOGRAPHY
// ============================================================================
function showBibliography(citationKey) {
highlightedCitation.value = citationKey;
bibliographyVisible.value = true;
}
// ============================================================================
// GEOMETRY LAYER UPDATES
// ============================================================================
@@ -996,6 +1013,16 @@ onMounted(() => {
// Load initial tiles for known sites
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)
}
});
});

View 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 landscapeparallel 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 contrastthis
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>

View File

@@ -15,14 +15,21 @@
<button @click="$emit('delete', feature.id)" class="popup-btn danger">Delete</button>
</div>
<!-- LINE or RAY -->
<div v-else-if="type === 'line' || type === 'ray'">
<strong>{{ type === 'line' ? 'Line' : 'Ray' }}</strong>
<!-- LINE -->
<div v-else-if="type === 'line'">
<strong>Line</strong>
<div>Length: {{ formatDistance(feature.properties.length, imperialUnits) }}</div>
<div>Bearing: {{ formatBearing(feature.properties.bearing) }}</div>
<button @click="$emit('delete', feature.id)" class="popup-btn danger">Delete</button>
</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 -->
<div v-else-if="type === 'site'" class="site-popup">
<strong>{{ feature.properties.name }}</strong>

View File

@@ -1,5 +1,13 @@
<template>
<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 -->
<!-- ============================================= -->
@@ -106,29 +114,13 @@
Imperial Units
</label>
</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>
</template>
<script setup>
import { ref } from 'vue';
import { KNOWN_SITES } from '../data/historicSites.js';
import TileRequestNotification from './TileRequestNotification.vue';
import AboutModal from './AboutModal.vue';
// ============================================================================
// INTERFACE
@@ -173,6 +165,8 @@ defineProps({
}
});
const aboutRef = ref(null);
defineEmits([
'update:baseLayer',
'update:historicMarkersExpanded',
@@ -212,6 +206,42 @@ const sites = KNOWN_SITES;
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 */
/* ============================================= */

View File

@@ -240,7 +240,6 @@ const initThreeJS = () => {
// Animation loop control
const startAnimation = () => {
console.log('starting animation')
if (animationId) return; // Already running
const animate = () => {
@@ -258,7 +257,6 @@ const startAnimation = () => {
};
const pauseAnimation = () => {
console.log('pausing animation')
animationPaused = true;
if (animationId) {
cancelAnimationFrame(animationId);
@@ -267,14 +265,12 @@ const pauseAnimation = () => {
};
const resumeAnimation = () => {
console.log('resuming animation')
animationPaused = false;
startAnimation();
};
// Render a single frame (for when animation is paused but we need to update the view)
const renderSingleFrame = () => {
console.log('Rendering single frame')
if (renderer && scene && camera) {
renderer.render(scene, camera);
}
@@ -312,8 +308,7 @@ const handleResize = () => {
// Load tile data
const loadTileData = (tileData, newTileId) => {
if (!scene) {
console.error('Three.js not initialized');
return false;
initThreeJS();
}
tileId.value = newTileId;
@@ -547,8 +542,7 @@ const renderTile = async () => {
// Designed to be called repeatedly without setup/teardown overhead
const renderTileWithSettings = async (tileData, renderSettings, resolution = 1024) => {
if (!renderer || !scene || !camera) {
console.error('Renderer not initialized');
return null;
initThreeJS();
}
const startTime = performance.now();

View File

@@ -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>

View File

@@ -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}.",
"type": "earthwork",
"tiles": ["BS19870742", 'BS19870743', 'BS19880743'],
"overlay" : [
{
"type": 'line',
"coordinates": [
[-82.459197, 40.027871],
[-82.458565, 40.028731]
]
}
]
// "overlay" : [
// {
// "type": 'line',
// "coordinates": [
// [-82.459197, 40.027871],
// [-82.458565, 40.028731]
// ]
// }
// ]
},
{
"name": "Van Voorhis Walls",
@@ -91,6 +91,13 @@ export const KNOWN_SITES = [
"type": "earthwork",
"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'
// 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

View File

@@ -1,13 +1,20 @@
/**
* Batch Renderer for Hopewell Lidar Tiles
*
* Usage in dev console with loaded app:
* Usage:
* 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
*/
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
* Batch render tiles using offscreen ShadingSandbox
*
* @param {Object} sandboxRef - Vue ref to ShadingSandbox component
* @param {Object} tileCache - Cache of loaded tile data
* @param {string[]} tileNames - Array of tile names to render
* @param {Object} sandboxComponent - Vue component instance (sandboxRef.value)
* @param {Object} tilesStore - Pinia tiles store instance
* @param {string[]} tileIds - Array of tile IDs to render (must be pre-loaded in store)
* @param {Object} options - Options
* @param {Object} options.renderSettings - Override render settings
* @param {number} options.renderQuality - Render quality (default: 1024)
* @param {number} options.renderQuality - Render quality in pixels (default: 1024)
*
* @returns {Promise<Object[]>} Array of results
*/
export async function batchRenderTiles(sandboxRef, tileCache, tileNames, options = {}) {
export async function batchRenderTiles(sandboxComponent, tilesStore, tileIds, options = {}) {
const {
renderSettings = null, // Use sandbox defaults if null
renderQuality = 1024
} = 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`);
const results = [];
const startTime = Date.now();
sandboxComponent.offscreen = true;
for (let i = 0; i < tileNames.length; i++) {
const tileName = tileNames[i];
for (let i = 0; i < tileIds.length; i++) {
const tileId = tileIds[i];
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 {
const tileData = tileCache[tileName];
if (!tileData) {
throw new Error(`Tile ${tileName} not found in cache`);
// Get tile data from store
const metadata = tilesStore.getMetadata(tileId);
const moundData = tilesStore.getMoundData(tileId);
if (!metadata) {
throw new Error(`Metadata not found in store for ${tileId}`);
}
// Render
console.log(`[BatchRenderer] Rendering...`);
const renderResult = await sandboxRef.renderTileWithSettings(
tileData,
renderSettings || sandboxRef.getSettings(),
if (!moundData) {
throw new Error(`Mound data not found in store for ${tileId}`);
}
// Render with current settings
console.log(`[BatchRenderer] Rendering at ${renderQuality}px...`);
const renderResult = await sandboxComponent.renderTileWithSettings(
moundData,
sandboxComponent.getSettings(),
renderQuality
);
@@ -88,37 +86,21 @@ export async function batchRenderTiles(sandboxRef, tileCache, tileNames, options
console.log(`[BatchRenderer] Rendered in ${renderResult.renderTime}ms`);
// Generate metadata
const metadata = {
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`);
// Download PNG
downloadDataURL(renderResult.dataURL, `${tileId}.png`);
results.push({
tileName,
metadata,
tileId,
renderTime: renderResult.renderTime,
success: true
});
console.log(`[BatchRenderer] ✓ Complete`);
// Small delay
await new Promise(resolve => setTimeout(resolve, 100));
} catch (err) {
console.error(`[BatchRenderer] ✗ Failed: ${err.message}`);
results.push({
tileName,
tileId,
success: false,
error: err.message
});

View File

@@ -38,7 +38,7 @@ export function formatDistance(meters, useImperial = false) {
* Format bearing angle
*/
export function formatBearing(degrees) {
return `${degrees.toFixed(1)}°`;
return `${degrees.toFixed(2)}°`;
}
/**

View File

@@ -40,7 +40,7 @@ export function calculateBearing(lng1, lat1, lng2, lat2) {
* Extend a line from point1 through point2 to a far distance (100km)
* 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 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];
}
/**
* 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;
}

View File

@@ -20,9 +20,14 @@ export default defineConfig({
main: resolve(__dirname, 'index.html'),
},
output: {
entryFileNames: 'app.js',
chunkFileNames: 'chunks/[name].js',
assetFileNames: 'assets/[name].[ext]'
entryFileNames: 'app-[hash].js',
chunkFileNames: 'chunks/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]',
manualChunks: {
'vendor': ['vue', 'pinia'],
'three': ['three'],
'maplibre': ['maplibre-gl']
}
}
}
},