add app layout
This commit is contained in:
@@ -1,5 +1,19 @@
|
||||
<script>
|
||||
import Layout from './lib/Layout.svelte';
|
||||
import WorldMap from './lib/WorldMap.svelte';
|
||||
import Timeline from './lib/Timeline.svelte';
|
||||
|
||||
let screen = $state('worldmap');
|
||||
|
||||
function onNavigate(s) {
|
||||
screen = s;
|
||||
}
|
||||
</script>
|
||||
|
||||
<WorldMap />
|
||||
<Layout {screen} {onNavigate}>
|
||||
{#if screen === 'worldmap'}
|
||||
<WorldMap />
|
||||
{:else}
|
||||
<Timeline />
|
||||
{/if}
|
||||
</Layout>
|
||||
|
||||
16
src/lib/Footer.svelte
Normal file
16
src/lib/Footer.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<footer class="footer">
|
||||
© 2026 Tomas Horsky & Haeri Kim
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.footer {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 24px;
|
||||
background: #dce8f0;
|
||||
font: 14px/1.5 sans-serif;
|
||||
color: #555;
|
||||
}
|
||||
</style>
|
||||
29
src/lib/Layout.svelte
Normal file
29
src/lib/Layout.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import TopBar from './TopBar.svelte';
|
||||
import Footer from './Footer.svelte';
|
||||
|
||||
let { screen, onNavigate, children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="layout">
|
||||
<TopBar {screen} {onNavigate} />
|
||||
<main class="main">
|
||||
{@render children()}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.layout {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
15
src/lib/Timeline.svelte
Normal file
15
src/lib/Timeline.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<div class="placeholder">
|
||||
<p>Timeline — coming soon</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font: 16px/1.4 sans-serif;
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
109
src/lib/TopBar.svelte
Normal file
109
src/lib/TopBar.svelte
Normal file
@@ -0,0 +1,109 @@
|
||||
<script>
|
||||
let { screen, onNavigate } = $props();
|
||||
</script>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="left">
|
||||
<img src="/logo.png" alt="Logo" class="logo" />
|
||||
<span class="app-name">Map Journal</span>
|
||||
</div>
|
||||
|
||||
<div class="center">
|
||||
<div class="segmented">
|
||||
<div
|
||||
class="slider"
|
||||
style="transform: translateX({screen === 'worldmap' ? 0 : 100}%);"
|
||||
></div>
|
||||
<button onclick={() => onNavigate('worldmap')}>Worldmap</button>
|
||||
<button onclick={() => onNavigate('timeline')}>Timeline</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<img src="/profile.jpg" alt="Profile" class="avatar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.topbar {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
background: #dce8f0;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 75px;
|
||||
height: 75px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font: 700 20px/1.2 sans-serif;
|
||||
color: #1f2937;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
position: relative;
|
||||
display: flex;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
border-radius: 999px;
|
||||
padding: 4px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
width: calc(50% - 4px);
|
||||
height: calc(100% - 8px);
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
transition: transform 0.25s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.segmented button {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font: 500 16px/1.4 sans-serif;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -4,21 +4,27 @@
|
||||
import { feature } from 'topojson-client';
|
||||
import worldData from 'world-atlas/countries-110m.json';
|
||||
|
||||
let container;
|
||||
let frameEl;
|
||||
|
||||
function fitProjection(proj, w, h) {
|
||||
proj.fitSize([w, h], { type: 'Sphere' });
|
||||
const s = proj.scale() * 1.5;
|
||||
proj.scale(s).translate([w / 2, h * 0.70]);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
const width = frameEl.clientWidth;
|
||||
const height = frameEl.clientHeight;
|
||||
|
||||
const projection = d3.geoMercator()
|
||||
.fitSize([width, height], { type: 'Sphere' });
|
||||
const projection = d3.geoMercator();
|
||||
fitProjection(projection, width, height);
|
||||
|
||||
const path = d3.geoPath().projection(projection);
|
||||
|
||||
const countries = feature(worldData, worldData.objects.countries)
|
||||
.features.filter(f => f.id !== '010');
|
||||
|
||||
const svg = d3.select(container)
|
||||
const svg = d3.select(frameEl)
|
||||
.append('svg')
|
||||
.attr('width', width)
|
||||
.attr('height', height);
|
||||
@@ -26,25 +32,21 @@
|
||||
const g = svg.append('g');
|
||||
const selected = new Set();
|
||||
|
||||
const tooltip = d3.select(container)
|
||||
const tooltip = d3.select(frameEl)
|
||||
.append('div')
|
||||
.attr('class', 'tooltip')
|
||||
.style('display', 'none');
|
||||
|
||||
function color(d) {
|
||||
return selected.has(d.id) ? '#4ade80' : '#e0e0e0';
|
||||
}
|
||||
|
||||
function updateFill(sel) {
|
||||
sel.attr('fill', d => selected.has(d.id) ? '#4ade80' : '#e0e0e0');
|
||||
sel.attr('fill', d => selected.has(d.id) ? '#22c55e' : '#ffffff');
|
||||
}
|
||||
|
||||
g.selectAll('path')
|
||||
.data(countries)
|
||||
.join('path')
|
||||
.attr('d', path)
|
||||
.attr('fill', color)
|
||||
.attr('stroke', '#999')
|
||||
.attr('fill', '#ffffff')
|
||||
.attr('stroke', '#d4d4d4')
|
||||
.attr('stroke-width', 0.5)
|
||||
.on('click', (event, d) => {
|
||||
if (selected.has(d.id)) {
|
||||
@@ -55,15 +57,15 @@
|
||||
updateFill(d3.select(event.currentTarget));
|
||||
})
|
||||
.on('mouseenter', (event, d) => {
|
||||
d3.select(event.currentTarget).attr('fill', selected.has(d.id) ? '#6ee7a0' : '#d0d0d0');
|
||||
d3.select(event.currentTarget).attr('fill', selected.has(d.id) ? '#16a34a' : '#f0f6fa');
|
||||
tooltip.style('display', 'block').text(d.properties.name);
|
||||
})
|
||||
.on('mousemove', (event) => {
|
||||
const [x, y] = d3.pointer(event, container);
|
||||
const [x, y] = d3.pointer(event, frameEl);
|
||||
tooltip.style('left', (x + 10) + 'px').style('top', (y - 28) + 'px');
|
||||
})
|
||||
.on('mouseleave', (event, d) => {
|
||||
d3.select(event.currentTarget).attr('fill', selected.has(d.id) ? '#4ade80' : '#e0e0e0');
|
||||
d3.select(event.currentTarget).attr('fill', selected.has(d.id) ? '#22c55e' : '#ffffff');
|
||||
tooltip.style('display', 'none');
|
||||
});
|
||||
|
||||
@@ -79,14 +81,14 @@
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
svg.attr('width', width).attr('height', height);
|
||||
projection.fitSize([width, height], { type: 'Sphere' });
|
||||
const paths = svg.selectAll('path');
|
||||
paths.attr('d', path);
|
||||
updateFill(paths);
|
||||
fitProjection(projection, width, height);
|
||||
const countryPaths = g.selectAll('path');
|
||||
countryPaths.attr('d', path);
|
||||
updateFill(countryPaths);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
observer.observe(frameEl);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
@@ -95,32 +97,44 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={container} class="map-container"></div>
|
||||
<div class="wrapper">
|
||||
<div bind:this={frameEl} class="map-frame"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.map-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.map-container :global(svg) {
|
||||
.map-frame {
|
||||
width: min(100%, calc((100vh - 96px) * 16 / 9));
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #dce8f0;
|
||||
}
|
||||
|
||||
.map-frame :global(svg) {
|
||||
display: block;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.map-container :global(svg:active) {
|
||||
.map-frame :global(svg:active) {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.map-container :global(svg path) {
|
||||
.map-frame :global(svg path) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.map-container :global(.tooltip) {
|
||||
.map-frame :global(.tooltip) {
|
||||
position: absolute;
|
||||
padding: 4px 10px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
background: #1f2937;
|
||||
color: #fff;
|
||||
font: 14px/1.4 sans-serif;
|
||||
border-radius: 4px;
|
||||
|
||||
Reference in New Issue
Block a user