add app layout

This commit is contained in:
2026-06-08 22:26:35 +09:00
parent 7a2d488f9c
commit 8976b94c41
11 changed files with 232 additions and 60 deletions

View File

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

@@ -0,0 +1,16 @@
<footer class="footer">
© 2026 Tomas Horsky &amp; 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
View 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
View 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
View 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>

View File

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