update vite config and added about modal

This commit is contained in:
2026-01-25 23:35:50 +01:00
parent 11bdb7009a
commit dbbf69566e
5 changed files with 416 additions and 4 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

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

@@ -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 -->
<!-- ============================================= -->
@@ -110,7 +118,9 @@
</template>
<script setup>
import { ref } from 'vue';
import { KNOWN_SITES } from '../data/historicSites.js';
import AboutModal from './AboutModal.vue';
// ============================================================================
// INTERFACE
@@ -155,6 +165,8 @@ defineProps({
}
});
const aboutRef = ref(null);
defineEmits([
'update:baseLayer',
'update:historicMarkersExpanded',
@@ -194,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

@@ -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']
}
}
}
},