mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-04-30 01:29:07 -05:00
725 lines
23 KiB
HTML
725 lines
23 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>The Tiny Torch Globe</title>
|
|
<link rel="icon" href="assets/flame.svg" type="image/svg+xml">
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
overflow: hidden;
|
|
font-family: 'Courier New', Courier, monospace;
|
|
|
|
/* Base Paper Color */
|
|
background-color: #f0f4f8;
|
|
|
|
/* Graph Paper Grid Logic */
|
|
background-image:
|
|
linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px),
|
|
linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px);
|
|
|
|
background-size: 20px 20px;
|
|
}
|
|
|
|
svg {
|
|
display: block;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
cursor: grab; /* Indicate draggable */
|
|
}
|
|
|
|
svg:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.label-box {
|
|
position: absolute;
|
|
top: 80px;
|
|
left: 20px;
|
|
padding: 15px;
|
|
background: rgba(255, 255, 255, 0.95);
|
|
border: 1px solid #333;
|
|
box-shadow: 4px 4px 0px rgba(0,0,0,0.1);
|
|
z-index: 10;
|
|
pointer-events: auto;
|
|
min-width: 200px;
|
|
max-width: 280px;
|
|
max-height: 240px;
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
|
|
.label-header {
|
|
border-bottom: 1px dashed #ccc;
|
|
padding-bottom: 8px;
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/* Search Input Styling */
|
|
.search-container {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.search-input {
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
padding: 6px;
|
|
font-family: 'Courier New', Courier, monospace;
|
|
font-size: 12px;
|
|
border: 1px solid #ccc;
|
|
outline: none;
|
|
}
|
|
|
|
.search-input:focus {
|
|
border-color: #ff6600;
|
|
background: #fffcf5;
|
|
}
|
|
|
|
.search-results {
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
width: 100%;
|
|
background: white;
|
|
border: 1px solid #ccc;
|
|
border-top: none;
|
|
max-height: 150px;
|
|
overflow-y: auto;
|
|
z-index: 20;
|
|
display: none;
|
|
box-shadow: 2px 2px 5px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.search-item {
|
|
padding: 6px;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
|
|
.search-item:hover {
|
|
background-color: #ffe0b2;
|
|
}
|
|
|
|
/* Key / Legend Styling */
|
|
.legend-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 11px;
|
|
color: #555;
|
|
}
|
|
|
|
.legend-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
background-color: #ff6600;
|
|
border-radius: 50%;
|
|
display: inline-block;
|
|
border: 1px solid #ffcc00;
|
|
box-shadow: 0 0 2px rgba(255, 102, 0, 0.6);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.legend-dot-institution {
|
|
width: 8px;
|
|
height: 8px;
|
|
background-color: #4a90e2;
|
|
border-radius: 50%;
|
|
display: inline-block;
|
|
border: 1px solid #a4c8f0;
|
|
box-shadow: 0 0 2px rgba(74, 144, 226, 0.6);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Toggle Button Styling */
|
|
.control-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
background: white;
|
|
border: 1px solid #555;
|
|
padding: 4px 8px;
|
|
font-family: 'Courier New', Courier, monospace;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
text-transform: uppercase;
|
|
transition: all 0.2s;
|
|
outline: none;
|
|
width: 100%;
|
|
justify-content: center;
|
|
}
|
|
|
|
.control-btn:hover {
|
|
background: #eee;
|
|
box-shadow: 1px 1px 0px #333;
|
|
transform: translate(-1px, -1px);
|
|
}
|
|
|
|
.control-btn:active {
|
|
transform: translate(0, 0);
|
|
box-shadow: none;
|
|
}
|
|
|
|
/* Pen Styling for the Globe */
|
|
.globe-outline-circle {
|
|
fill: #fff; /* White background for the sphere itself */
|
|
stroke: #333;
|
|
stroke-width: 2px;
|
|
pointer-events: none; /* Ensure globe bg doesn't block markers */
|
|
}
|
|
|
|
.graticule {
|
|
fill: none;
|
|
stroke: #ccc; /* Light pencil lines for grid */
|
|
stroke-width: 0.5px;
|
|
stroke-dasharray: 2,2; /* Dashed lines for technical feel */
|
|
pointer-events: none;
|
|
}
|
|
|
|
.country {
|
|
fill: #fff; /* Paper color fill */
|
|
stroke: #2c3e50; /* Dark ink stroke */
|
|
stroke-width: 1.2px;
|
|
stroke-linejoin: round;
|
|
stroke-linecap: round;
|
|
pointer-events: none; /* Let events fall through to globe/markers */
|
|
}
|
|
|
|
/* Particle Marker Styling */
|
|
.marker {
|
|
stroke: #ffcc00; /* Lighter orange stroke */
|
|
stroke-width: 1px;
|
|
fill: #ff6600; /* Bright orange fill */
|
|
filter: drop-shadow(0 0 4px rgba(255, 102, 0, 0.6)); /* Glow effect */
|
|
cursor: pointer;
|
|
transition: r 0.2s ease, fill 0.2s ease;
|
|
pointer-events: all !important; /* Force events on markers */
|
|
}
|
|
|
|
.marker:hover {
|
|
fill: #ff9900;
|
|
stroke: #fff;
|
|
}
|
|
|
|
/* Institution Marker Styling */
|
|
.institution-marker {
|
|
cursor: pointer;
|
|
pointer-events: all !important;
|
|
}
|
|
|
|
.institution-marker:hover {
|
|
/* transform: scale(1.15) !important; */
|
|
}
|
|
|
|
.institution-building {
|
|
fill: rgba(255, 255, 255, 0.85);
|
|
stroke: #4a90e2;
|
|
stroke-width: 0.8;
|
|
filter: drop-shadow(0 0 3px rgba(74, 144, 226, 0.4));
|
|
}
|
|
|
|
.building-windows rect {
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* Tooltip / Mini Modal Styling */
|
|
.tooltip {
|
|
position: absolute;
|
|
background: #fff;
|
|
border: 1px solid #333;
|
|
padding: 12px;
|
|
border-radius: 4px;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
box-shadow: 4px 4px 0px rgba(0,0,0,0.2);
|
|
font-family: 'Arial', sans-serif; /* Clean font for data */
|
|
font-size: 13px;
|
|
min-width: 150px;
|
|
z-index: 100; /* High z-index to be sure */
|
|
}
|
|
|
|
.tooltip h3 {
|
|
margin: 0 0 5px 0;
|
|
font-size: 14px;
|
|
color: #333;
|
|
border-bottom: 1px solid #eee;
|
|
padding-bottom: 5px;
|
|
}
|
|
|
|
.tooltip .info-row {
|
|
margin: 4px 0;
|
|
color: #555;
|
|
}
|
|
|
|
.tooltip .highlight {
|
|
color: #ff6600;
|
|
font-weight: bold;
|
|
}
|
|
</style>
|
|
|
|
<!-- Load D3 and TopoJSON -->
|
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
<script src="https://unpkg.com/topojson-client@3"></script>
|
|
</head>
|
|
|
|
<body>
|
|
<div class="label-box">
|
|
<div class="label-header">
|
|
<strong>HELLO WORLD!</strong><br>
|
|
Tiny Torch Community<br>
|
|
<small style="color:#777; font-size:10px;">Hover over a node to view user</small>
|
|
</div>
|
|
|
|
<!-- Search UI -->
|
|
<div class="search-container">
|
|
<input type="text" id="user-search" class="search-input" placeholder="Search user..." autocomplete="off">
|
|
<div id="search-dropdown" class="search-results"></div>
|
|
</div>
|
|
|
|
<button id="rotation-toggle" class="control-btn">
|
|
<span>❚❚</span> Pause Rotation
|
|
</button>
|
|
|
|
<div class="legend-row">
|
|
<span class="legend-dot"></span>
|
|
<span id="member-count">0 Members on Map</span>
|
|
</div>
|
|
<div class="legend-row">
|
|
<span class="legend-dot" style="background-color: #2ecc71; border-color: #27ae60;"></span>
|
|
<span id="no-location-count">0 without Location (Lost at Sea)</span>
|
|
</div>
|
|
<div class="legend-row">
|
|
<span class="legend-dot-institution"></span>
|
|
<span id="institution-count">0 Institutions</span>
|
|
</div>
|
|
<div class="legend-row" style="margin-top: 5px; border-top: 1px dashed #eee; padding-top: 8px; font-style: italic; color: #888;">
|
|
Note: Members without a location set in their profile are placed at the Sea Station.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tooltip Container -->
|
|
<div id="tooltip" class="tooltip"></div>
|
|
|
|
<svg id="globe-svg"></svg>
|
|
|
|
<script type="module">
|
|
import './app.js'; // Keep existing app logic
|
|
|
|
const width = window.innerWidth;
|
|
const height = window.innerHeight;
|
|
|
|
// Configuration
|
|
const config = {
|
|
speed: 0.3,
|
|
verticalTilt: -20,
|
|
scale: Math.min(width, height) / 2.5,
|
|
lostStationCoords: [-135, -35] // Point in the Pacific
|
|
};
|
|
|
|
// Major cities for rough location mapping
|
|
const CITIES = [
|
|
{lat: 34.0522, lng: -118.2437, name: "Los Angeles"},
|
|
{lat: 40.7128, lng: -74.0060, name: "New York"},
|
|
{lat: 51.5074, lng: -0.1278, name: "London"},
|
|
{lat: 35.6895, lng: 139.6917, name: "Tokyo"},
|
|
{lat: -33.8688, lng: 151.2093, name: "Sydney"},
|
|
{lat: 19.4326, lng: -99.1332, name: "Mexico City"},
|
|
{lat: -23.5505, lng: -46.6333, name: "Sao Paulo"},
|
|
{lat: 30.0444, lng: 31.2357, name: "Cairo"},
|
|
{lat: 28.6139, lng: 77.2090, name: "New Delhi"},
|
|
{lat: 55.7558, lng: 37.6173, name: "Moscow"},
|
|
{lat: -1.2921, lng: 36.8219, name: "Nairobi"},
|
|
{lat: 48.8566, lng: 2.3522, name: "Paris"},
|
|
{lat: 39.9042, lng: 116.4074, name: "Beijing"},
|
|
{lat: 13.7563, lng: 100.5018, name: "Bangkok"},
|
|
{lat: -26.2041, lng: 28.0473, name: "Johannesburg"},
|
|
{lat: 41.0082, lng: 28.9784, name: "Istanbul"},
|
|
{lat: 6.5244, lng: 3.3792, name: "Lagos"},
|
|
{lat: 37.7749, lng: -122.4194, name: "San Francisco"},
|
|
{lat: 1.3521, lng: 103.8198, name: "Singapore"},
|
|
{lat: 52.5200, lng: 13.4050, name: "Berlin"},
|
|
{lat: 22.5726, lng: 88.3639, name: "Kolkata"}
|
|
];
|
|
|
|
let totalMemberCount = 0;
|
|
let locations = [];
|
|
let lostUsers = [];
|
|
let institutions = [];
|
|
const API_URL = "https://zrvmjrxhokwwmjacyhpq.supabase.co/functions/v1/search-profiles";
|
|
|
|
const svg = d3.select('#globe-svg');
|
|
const tooltip = d3.select('#tooltip');
|
|
|
|
// Projection setup
|
|
const projection = d3.geoOrthographic()
|
|
.scale(config.scale)
|
|
.center([0, 0])
|
|
.translate([width / 2, height / 2]);
|
|
|
|
const path = d3.geoPath().projection(projection);
|
|
const center = [width/2, height/2];
|
|
|
|
// State
|
|
let currentRotation = [0, config.verticalTilt, 0];
|
|
let isDragging = false;
|
|
let isPaused = false;
|
|
let isHovering = false;
|
|
|
|
// Groups
|
|
const globeGroup = svg.append('g');
|
|
const graticuleGroup = svg.append('g');
|
|
const mapGroup = svg.append('g');
|
|
const institutionGroup = svg.append('g');
|
|
const lostStationGroup = svg.append('g');
|
|
const markerGroup = svg.append('g');
|
|
|
|
// 1. Globe Background
|
|
globeGroup.append("path")
|
|
.datum({type: "Sphere"})
|
|
.attr("class", "globe-outline-circle")
|
|
.attr("d", path);
|
|
|
|
// 2. Graticules
|
|
const graticule = d3.geoGraticule().step([10, 10]);
|
|
graticuleGroup.append("path")
|
|
.datum(graticule)
|
|
.attr("class", "graticule")
|
|
.attr("d", path);
|
|
|
|
// 3. Load World Data
|
|
d3.json('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json')
|
|
.then(worldData => {
|
|
// Optimized: Render countries as a single path
|
|
mapGroup.append("path")
|
|
.datum(topojson.feature(worldData, worldData.objects.countries))
|
|
.attr("class", "country")
|
|
.attr("d", path);
|
|
|
|
startAnimation();
|
|
fetchProfiles();
|
|
});
|
|
|
|
// API Fetch Logic
|
|
async function fetchProfiles(query = "") {
|
|
try {
|
|
const response = await fetch(API_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ query: query, page: 0, limit: 1000 })
|
|
});
|
|
|
|
if (!response.ok) throw new Error("API Error");
|
|
const data = await response.json();
|
|
|
|
if (Array.isArray(data) && data.length > 0) {
|
|
totalMemberCount = data.length;
|
|
let mapUsers = [];
|
|
let tempLostUsers = [];
|
|
|
|
data.forEach(p => {
|
|
const lat = parseFloat(p.latitude);
|
|
const lng = parseFloat(p.longitude);
|
|
const isLostLabel = p.location === "Lost at Sea 🌊";
|
|
const hasNoLocation = !p.location || p.location === "unknown";
|
|
const hasNoCoords = isNaN(lat) || isNaN(lng) || (Math.abs(lat) < 0.001 && Math.abs(lng) < 0.001);
|
|
|
|
const displayName = (p.display_name && p.display_name.trim()) ? p.display_name.trim() : (p.username ? p.username.split('@')[0].trim() : 'Anonymous');
|
|
|
|
const userObj = {
|
|
user: p.username,
|
|
displayName: displayName,
|
|
completed: "Member",
|
|
institution: Array.isArray(p.institution) ? p.institution.join(", ") : (p.institution || "Independent"),
|
|
location: p.location || null
|
|
};
|
|
|
|
if (isLostLabel || (hasNoLocation && hasNoCoords)) {
|
|
tempLostUsers.push(userObj);
|
|
} else if (!hasNoCoords) {
|
|
mapUsers.push({
|
|
...userObj,
|
|
latitude: lat + (Math.random() - 0.5) * 0.2,
|
|
longitude: lng + (Math.random() - 0.5) * 0.2
|
|
});
|
|
} else {
|
|
tempLostUsers.push(userObj);
|
|
}
|
|
});
|
|
|
|
locations = mapUsers;
|
|
lostUsers = tempLostUsers;
|
|
|
|
renderMarkers();
|
|
renderInstitutions();
|
|
renderLostStation();
|
|
} else {
|
|
if (query) { locations = []; lostUsers = []; totalMemberCount = 0; }
|
|
}
|
|
} catch (e) {
|
|
locations = [];
|
|
lostUsers = [];
|
|
totalMemberCount = 0;
|
|
}
|
|
renderMarkers();
|
|
renderInstitutions();
|
|
renderLostStation();
|
|
if (query) updateSearchDropdown(query);
|
|
}
|
|
|
|
// Render Markers
|
|
function renderMarkers() {
|
|
const markers = markerGroup.selectAll('circle.marker')
|
|
.data(locations, d => d.user);
|
|
|
|
markers.exit().remove();
|
|
|
|
markers.enter()
|
|
.append('circle')
|
|
.attr('class', 'marker')
|
|
.attr('r', 4)
|
|
.on("mouseover", function(event, d) {
|
|
isHovering = true;
|
|
d3.select(this).attr("r", 6);
|
|
showTooltip(event, d);
|
|
})
|
|
.on("mouseout", function() {
|
|
isHovering = false;
|
|
d3.select(this).attr("r", 4);
|
|
tooltip.style("opacity", 0);
|
|
})
|
|
.style('opacity', 0)
|
|
.transition().duration(300).style('opacity', 1);
|
|
|
|
document.getElementById('member-count').textContent = `${locations.length} Members on Map`;
|
|
document.getElementById('no-location-count').textContent = `${lostUsers.length} without Location (Lost at Sea)`;
|
|
updateMarkerPositions();
|
|
}
|
|
|
|
function renderLostStation() {
|
|
const station = lostStationGroup.selectAll('g.lost-station')
|
|
.data(lostUsers.length > 0 ? [{count: lostUsers.length}] : []);
|
|
|
|
station.exit().remove();
|
|
|
|
const enter = station.enter()
|
|
.append('g')
|
|
.attr('class', 'lost-station')
|
|
.style('cursor', 'pointer')
|
|
.on("mouseover", function(event, d) {
|
|
isHovering = true;
|
|
tooltip.html(`<h3>🌊 Sea Station</h3><div class="info-row"><span class="highlight">${d.count}</span> Members drifting here...</div><div class="info-row" style="font-size:10px; color:#888;">(Update location in profile to be found)</div>`);
|
|
tooltip.style("left", (event.pageX + 15) + "px").style("top", (event.pageY - 15) + "px").style("opacity", 1);
|
|
})
|
|
.on("mouseout", function() {
|
|
isHovering = false;
|
|
tooltip.style("opacity", 0);
|
|
});
|
|
|
|
// Buoy/Station Visual
|
|
enter.append('circle').attr('r', 12).attr('fill', 'rgba(46, 204, 113, 0.2)').attr('stroke', '#2ecc71').attr('stroke-width', 1).attr('stroke-dasharray', '2,2');
|
|
enter.append('circle').attr('r', 4).attr('fill', '#2ecc71').attr('stroke', '#fff').attr('stroke-width', 1);
|
|
|
|
updateLostStationPosition();
|
|
}
|
|
|
|
function showTooltip(event, d) {
|
|
tooltip.html(`
|
|
<h3>${d.displayName}</h3>
|
|
<div class="info-row">Status: <span class="highlight">${d.completed}</span></div>
|
|
<div class="info-row"><i>${d.institution}</i></div>
|
|
`);
|
|
tooltip.style("left", (event.pageX + 15) + "px").style("top", (event.pageY - 15) + "px").style("opacity", 1);
|
|
}
|
|
|
|
// Render Institutions
|
|
function renderInstitutions() {
|
|
const institutionMap = new Map();
|
|
locations.forEach(loc => {
|
|
if (loc.institution && loc.institution !== "Independent") {
|
|
const inst = loc.institution;
|
|
if (!institutionMap.has(inst)) {
|
|
institutionMap.set(inst, {
|
|
name: inst,
|
|
latitude: loc.latitude,
|
|
longitude: loc.longitude,
|
|
members: []
|
|
});
|
|
}
|
|
institutionMap.get(inst).members.push(loc.displayName);
|
|
}
|
|
});
|
|
institutions = Array.from(institutionMap.values());
|
|
|
|
const instMarkers = institutionGroup.selectAll('g.institution-marker')
|
|
.data(institutions, d => d.name);
|
|
|
|
instMarkers.exit().remove();
|
|
|
|
const enter = instMarkers.enter()
|
|
.append('g')
|
|
.attr('class', 'institution-marker')
|
|
.on("mouseover", function(event, d) {
|
|
isHovering = true;
|
|
d3.select(this).select('.institution-building').style('stroke', '#ff6600');
|
|
tooltip.html(`<h3>🏛️ ${d.name}</h3><div class="info-row">Members: <span class="highlight">${d.members.length}</span></div>`);
|
|
tooltip.style("left", (event.pageX + 15) + "px").style("top", (event.pageY - 15) + "px").style("opacity", 1);
|
|
})
|
|
.on("mouseout", function() {
|
|
isHovering = false;
|
|
d3.select(this).select('.institution-building').style('stroke', '#4a90e2');
|
|
tooltip.style("opacity", 0);
|
|
});
|
|
|
|
document.getElementById('institution-count').textContent = `${institutions.length} Institutions`;
|
|
|
|
enter.append('rect').attr('class', 'institution-building').attr('x', -5).attr('y', -8).attr('width', 10).attr('height', 8);
|
|
const windowsGroup = enter.append('g').attr('class', 'building-windows');
|
|
const windows = [{x:-4,y:-7},{x:-1,y:-7},{x:2.5,y:-7},{x:-4,y:-4.5},{x:-1,y:-4.5},{x:2.5,y:-4.5},{x:-4,y:-2},{x:2.5,y:-2}];
|
|
windows.forEach(w => windowsGroup.append('rect').attr('x', w.x).attr('y', w.y).attr('width', 1.5).attr('height', 1.5).attr('fill', 'rgba(74, 144, 226, 0.6)'));
|
|
|
|
updateInstitutionPositions();
|
|
}
|
|
|
|
// Update Globe Positions
|
|
function updateMarkerPositions() {
|
|
const markers = markerGroup.selectAll('circle.marker');
|
|
if (markers.empty()) return;
|
|
|
|
const invCenter = projection.invert(center);
|
|
|
|
markers.each(function(d) {
|
|
const coordinate = [d.longitude, d.latitude];
|
|
const gdistance = d3.geoDistance(coordinate, invCenter);
|
|
if (gdistance < 1.57) {
|
|
const pos = projection(coordinate);
|
|
d3.select(this).attr('cx', pos[0]).attr('cy', pos[1]).style('display', 'block');
|
|
} else {
|
|
d3.select(this).style('display', 'none');
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateLostStationPosition() {
|
|
const station = lostStationGroup.selectAll('g.lost-station');
|
|
if (station.empty()) return;
|
|
|
|
const invCenter = projection.invert(center);
|
|
const coordinate = config.lostStationCoords;
|
|
const gdistance = d3.geoDistance(coordinate, invCenter);
|
|
|
|
if (gdistance < 1.57) {
|
|
const pos = projection(coordinate);
|
|
station.attr('transform', `translate(${pos[0]},${pos[1]})`).style('display', 'block');
|
|
} else {
|
|
station.style('display', 'none');
|
|
}
|
|
}
|
|
|
|
function updateInstitutionPositions() {
|
|
const instMarkers = institutionGroup.selectAll('g.institution-marker');
|
|
if (instMarkers.empty()) return;
|
|
|
|
const invCenter = projection.invert(center);
|
|
|
|
instMarkers.each(function(d) {
|
|
const coordinate = [d.longitude, d.latitude];
|
|
const gdistance = d3.geoDistance(coordinate, invCenter);
|
|
if (gdistance < 1.57) {
|
|
const pos = projection(coordinate);
|
|
d3.select(this).attr('transform', `translate(${pos[0]},${pos[1]})`).style('display', 'block');
|
|
} else {
|
|
d3.select(this).style('display', 'none');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Animation
|
|
function startAnimation() {
|
|
d3.timer(function () {
|
|
if (!isDragging && !isPaused && !isHovering) {
|
|
currentRotation[0] += config.speed;
|
|
projection.rotate(currentRotation);
|
|
redraw();
|
|
}
|
|
});
|
|
}
|
|
|
|
function redraw() {
|
|
graticuleGroup.select("path").attr("d", path);
|
|
mapGroup.select("path").attr("d", path);
|
|
updateMarkerPositions();
|
|
updateInstitutionPositions();
|
|
updateLostStationPosition();
|
|
}
|
|
|
|
// Input Handling
|
|
const drag = d3.drag()
|
|
.on("start", () => { isDragging = true; })
|
|
.on("drag", (event) => {
|
|
const k = 75 / projection.scale();
|
|
projection.rotate([ projection.rotate()[0] + event.dx * k, projection.rotate()[1] - event.dy * k ]);
|
|
currentRotation = projection.rotate();
|
|
redraw();
|
|
})
|
|
.on("end", () => { isDragging = false; });
|
|
svg.call(drag);
|
|
|
|
document.getElementById('rotation-toggle').addEventListener('click', () => { isPaused = !isPaused; });
|
|
|
|
const searchInput = document.getElementById('user-search');
|
|
const searchDropdown = document.getElementById('search-dropdown');
|
|
let debounceTimer;
|
|
searchInput.addEventListener('input', (e) => {
|
|
clearTimeout(debounceTimer);
|
|
if (!e.target.value) { searchDropdown.style.display = 'none'; fetchProfiles(""); return; }
|
|
debounceTimer = setTimeout(() => { fetchProfiles(e.target.value); }, 500);
|
|
});
|
|
|
|
function updateSearchDropdown(query) {
|
|
searchDropdown.innerHTML = '';
|
|
const matches = locations.slice(0, 8);
|
|
if (matches.length > 0) {
|
|
searchDropdown.style.display = 'block';
|
|
matches.forEach(match => {
|
|
const div = document.createElement('div');
|
|
div.className = 'search-item';
|
|
div.textContent = match.displayName;
|
|
div.onclick = () => { searchInput.value = match.displayName; searchDropdown.style.display = 'none'; focusUser(match); };
|
|
searchDropdown.appendChild(div);
|
|
});
|
|
} else { searchDropdown.style.display = 'none'; }
|
|
}
|
|
|
|
function focusUser(d) {
|
|
if (!isPaused) isPaused = true;
|
|
const targetRotation = [-d.longitude, -d.latitude];
|
|
d3.transition().duration(1500).tween("rotate", () => {
|
|
const r = d3.interpolate(projection.rotate(), targetRotation);
|
|
return (t) => { projection.rotate(r(t)); currentRotation = projection.rotate(); redraw(); };
|
|
});
|
|
}
|
|
|
|
window.addEventListener('resize', () => {
|
|
const w = window.innerWidth;
|
|
const h = window.innerHeight;
|
|
svg.attr('width', w).attr('height', h);
|
|
projection.translate([w/2, h/2]).scale(Math.min(w, h) / 2.5);
|
|
center[0] = w/2; center[1] = h/2;
|
|
|
|
// Update static globe background
|
|
globeGroup.select("path").attr("d", path);
|
|
|
|
redraw();
|
|
});
|
|
|
|
</script>
|
|
</body>
|
|
</html>
|