add globe animation
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
let journeyProgress = $state(null);
|
||||
let inDetail = $state(false);
|
||||
let pendingCountry = $state('');
|
||||
let journeyMode = $state('map');
|
||||
|
||||
function onNavigate(s) {
|
||||
screen = s;
|
||||
@@ -56,10 +57,10 @@
|
||||
<div class="worldmap-page">
|
||||
<div class="map-area">
|
||||
{#if journeyActive}
|
||||
<JourneyView onclose={endJourney} onprogress={onJourneyProgress} />
|
||||
<JourneyView onclose={endJourney} onprogress={onJourneyProgress} mode={journeyMode} onmodechange={(m) => journeyMode = m} />
|
||||
{:else}
|
||||
<WorldMap onCountryClick={handleCountryClick} />
|
||||
<button class="journey-play-btn" onclick={startJourney}>▶</button>
|
||||
<button class="journey-play-btn" onclick={startJourney}>▶ Replay My Trips</button>
|
||||
{/if}
|
||||
</div>
|
||||
<StatsPanel />
|
||||
@@ -114,14 +115,14 @@
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 10;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
padding: 12px 28px;
|
||||
border-radius: 24px;
|
||||
border: none;
|
||||
background: #8b5cf6;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { feature } from 'topojson-client';
|
||||
import worldData from 'world-atlas/countries-50m.json';
|
||||
|
||||
let { onclose, onprogress } = $props();
|
||||
let { onclose, onprogress, mode = 'map', onmodechange } = $props();
|
||||
|
||||
const HOME_CODE = '203';
|
||||
|
||||
@@ -40,18 +40,22 @@
|
||||
}
|
||||
|
||||
let frameEl;
|
||||
let svg, g, pathFn, projection;
|
||||
let svg, gBase, gCountries, gAnim, pathFn, projection;
|
||||
let countryPaths;
|
||||
let homeFeature;
|
||||
let featuresById = {};
|
||||
let countriesData = [];
|
||||
let isCancelled = false;
|
||||
let isPlaying = $state(false);
|
||||
let isFinished = $state(false);
|
||||
let visitedCodes = new Set();
|
||||
let animId = 0;
|
||||
let currentDateLabel = $state('');
|
||||
|
||||
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]);
|
||||
function formatDateLabel(dateStr) {
|
||||
const d = new Date(dateStr);
|
||||
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
return `${months[d.getMonth()]} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
function computeArc(p1, p2) {
|
||||
@@ -110,7 +114,6 @@
|
||||
const angle = getAngleAtLength(node, clamped);
|
||||
tipEl.attr('transform', `translate(${pt.x}, ${pt.y}) rotate(${angle}) scale(1.4)`).attr('opacity', 1);
|
||||
} catch (e) {
|
||||
// ignore SVG errors
|
||||
}
|
||||
|
||||
if (t >= 1) {
|
||||
@@ -128,41 +131,200 @@
|
||||
});
|
||||
}
|
||||
|
||||
function setupProjection(width, height) {
|
||||
if (mode === 'map') {
|
||||
projection = d3.geoMercator();
|
||||
projection.fitSize([width, height], { type: 'Sphere' });
|
||||
const s = projection.scale() * 1.5;
|
||||
projection.scale(s).translate([width / 2, height * 0.70]);
|
||||
} else {
|
||||
const size = Math.min(width, height) * 0.92;
|
||||
projection = d3.geoOrthographic()
|
||||
.rotate([0, 0])
|
||||
.fitSize([size, size], { type: 'Sphere' })
|
||||
.translate([width / 2, height / 2]);
|
||||
}
|
||||
pathFn = d3.geoPath().projection(projection);
|
||||
}
|
||||
|
||||
function renderMap() {
|
||||
gBase.selectAll('*').remove();
|
||||
gCountries.selectAll('*').remove();
|
||||
gAnim.selectAll('*').remove();
|
||||
|
||||
const fillFn = d => {
|
||||
const id = effId(d);
|
||||
if (id === HOME_CODE) return visitedCodes.has(id) ? VISITED_COLOR : HOME_COLOR;
|
||||
if (visitedCodes.has(id)) return VISITED_COLOR;
|
||||
return UNVISITED;
|
||||
};
|
||||
|
||||
if (mode === 'globe') {
|
||||
gBase.append('path')
|
||||
.attr('class', 'sphere')
|
||||
.datum({ type: 'Sphere' })
|
||||
.attr('d', pathFn)
|
||||
.attr('fill', '#a4c8e0')
|
||||
.attr('stroke', '#8b9bb0')
|
||||
.attr('stroke-width', 1.5);
|
||||
|
||||
}
|
||||
|
||||
countryPaths = gCountries.selectAll('path')
|
||||
.data(countriesData, d => effId(d))
|
||||
.join('path')
|
||||
.attr('d', pathFn)
|
||||
.attr('fill', fillFn)
|
||||
.attr('stroke', mode === 'globe' ? '#4a6a8c' : '#d4d4d4')
|
||||
.attr('stroke-width', mode === 'globe' ? 0.3 : 0.5);
|
||||
|
||||
if (mode === 'map') {
|
||||
renderMicrostates();
|
||||
}
|
||||
}
|
||||
|
||||
function renderMicrostates() {
|
||||
gBase.selectAll('.micro-state-j').remove();
|
||||
const threshold = 4;
|
||||
const fillFn = d => {
|
||||
const id = effId(d);
|
||||
if (id === HOME_CODE) return visitedCodes.has(id) ? VISITED_COLOR : HOME_COLOR;
|
||||
if (visitedCodes.has(id)) return VISITED_COLOR;
|
||||
return UNVISITED;
|
||||
};
|
||||
countryPaths.each(function (d) {
|
||||
if (effId(d) !== d.id) return;
|
||||
const { width, height } = this.getBBox();
|
||||
if (width < threshold && height < threshold) {
|
||||
const [cx, cy] = pathFn.centroid(d);
|
||||
gBase.append('circle')
|
||||
.attr('class', 'micro-state-j')
|
||||
.datum(d)
|
||||
.attr('cx', cx)
|
||||
.attr('cy', cy)
|
||||
.attr('r', 2)
|
||||
.attr('fill', fillFn(d))
|
||||
.attr('stroke', '#94a3b8')
|
||||
.attr('stroke-width', 0.5);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function redrawBase() {
|
||||
countryPaths.attr('d', pathFn);
|
||||
if (mode === 'globe') {
|
||||
gBase.select('.sphere').attr('d', pathFn);
|
||||
}
|
||||
}
|
||||
|
||||
function rotateGlobeTo(lon, lat, duration = 1500) {
|
||||
return new Promise((resolve) => {
|
||||
if (isCancelled) { resolve(); return; }
|
||||
|
||||
const current = projection.rotate();
|
||||
const from = [-current[0], -current[1]];
|
||||
const to = [lon, lat];
|
||||
|
||||
const interpolate = d3.geoInterpolate(from, to);
|
||||
|
||||
const timer = d3.timer(elapsed => {
|
||||
if (isCancelled) { timer.stop(); resolve(); return true; }
|
||||
|
||||
const t = Math.min(elapsed / duration, 1);
|
||||
const point = interpolate(t);
|
||||
projection.rotate([-point[0], -point[1]]);
|
||||
redrawBase();
|
||||
|
||||
if (t >= 1) {
|
||||
timer.stop();
|
||||
resolve();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function animateReprojectingArc(el, tip, geoPts, lineGen, duration) {
|
||||
return new Promise((resolve) => {
|
||||
const steps = geoPts.length - 1;
|
||||
|
||||
const timer = d3.timer(elapsed => {
|
||||
if (isCancelled) { timer.stop(); resolve(); return true; }
|
||||
|
||||
const t = Math.min(elapsed / duration, 1);
|
||||
const count = Math.max(2, Math.floor(t * steps) + 1);
|
||||
const visible = geoPts.slice(0, count);
|
||||
const screenPts = [];
|
||||
for (const p of visible) {
|
||||
const pt = projection(p);
|
||||
if (pt) screenPts.push(pt);
|
||||
}
|
||||
|
||||
if (screenPts.length >= 2) {
|
||||
el.attr('d', lineGen(screenPts));
|
||||
}
|
||||
|
||||
if (screenPts.length > 0) {
|
||||
const last = screenPts[screenPts.length - 1];
|
||||
let angle = 0;
|
||||
if (screenPts.length >= 2) {
|
||||
const prev = screenPts[screenPts.length - 2];
|
||||
angle = Math.atan2(last[1] - prev[1], last[0] - prev[0]) * 180 / Math.PI;
|
||||
}
|
||||
tip.attr('transform', `translate(${last[0]},${last[1]}) rotate(${angle}) scale(1.4)`)
|
||||
.attr('opacity', 1);
|
||||
}
|
||||
|
||||
if (t >= 1) {
|
||||
timer.stop();
|
||||
resolve();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function animateTrip(destCode, destFeature) {
|
||||
if (!homeFeature || !destFeature) return;
|
||||
|
||||
const homeCentroid = d3.geoCentroid(homeFeature);
|
||||
const destCentroid = d3.geoCentroid(destFeature);
|
||||
|
||||
if (mode === 'map') {
|
||||
await animateMapTrip(homeCentroid, destCentroid, destCode);
|
||||
} else {
|
||||
await animateGlobeTrip(homeCentroid, destCentroid, destCode);
|
||||
}
|
||||
}
|
||||
|
||||
async function animateMapTrip(homeCentroid, destCentroid, destCode) {
|
||||
const pts = computeArc(homeCentroid, destCentroid);
|
||||
if (pts.length < 2) return;
|
||||
|
||||
const lineGen = d3.line().curve(d3.curveBasis);
|
||||
const pathData = lineGen(pts);
|
||||
|
||||
if (!pathData) return;
|
||||
|
||||
function createArc(pathData) {
|
||||
const el = g.append('path')
|
||||
const el = gAnim.append('path')
|
||||
.attr('d', pathData)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', ARC_COLOR)
|
||||
.attr('stroke-width', 2.5)
|
||||
.attr('stroke-opacity', 0.8)
|
||||
.attr('stroke-linecap', 'round');
|
||||
const tip = g.append('path')
|
||||
const tip = gAnim.append('path')
|
||||
.attr('d', PLANE_PATH)
|
||||
.attr('fill', PLANE_COLOR)
|
||||
.attr('opacity', 0);
|
||||
return { el, tip };
|
||||
}
|
||||
|
||||
// --- Outbound: home -> dest ---
|
||||
let { el: outEl, tip: outTip } = createArc(pathData);
|
||||
const outLen = outEl.node().getTotalLength();
|
||||
outEl.attr('stroke-dasharray', outLen).attr('stroke-dashoffset', outLen);
|
||||
|
||||
const homeDot = g.append('circle')
|
||||
const homeDot = gAnim.append('circle')
|
||||
.attr('r', 4)
|
||||
.attr('fill', PLANE_COLOR)
|
||||
.attr('cx', pts[0][0])
|
||||
@@ -176,10 +338,10 @@
|
||||
outTip.remove();
|
||||
homeDot.remove();
|
||||
|
||||
// Color the destination country
|
||||
const targetPath = countryPaths.filter(d => effId(d) === destCode);
|
||||
targetPath.transition().duration(500).attr('fill', VISITED_COLOR);
|
||||
g.selectAll('.micro-state-j')
|
||||
visitedCodes.add(destCode);
|
||||
gBase.selectAll('.micro-state-j')
|
||||
.filter(d => effId(d) === destCode)
|
||||
.transition().duration(500)
|
||||
.attr('fill', VISITED_COLOR);
|
||||
@@ -187,14 +349,13 @@
|
||||
await delay(800);
|
||||
if (isCancelled) return;
|
||||
|
||||
// --- Return: dest -> home ---
|
||||
const revPts = [...pts].reverse();
|
||||
const revData = d3.line().curve(d3.curveBasis)(revPts);
|
||||
let { el: retEl, tip: retTip } = createArc(revData);
|
||||
const retLen = retEl.node().getTotalLength();
|
||||
retEl.attr('stroke-dasharray', retLen).attr('stroke-dashoffset', retLen);
|
||||
|
||||
const destDot = g.append('circle')
|
||||
const destDot = gAnim.append('circle')
|
||||
.attr('r', 4)
|
||||
.attr('fill', PLANE_COLOR)
|
||||
.attr('cx', revPts[0][0])
|
||||
@@ -211,18 +372,104 @@
|
||||
await delay(300);
|
||||
}
|
||||
|
||||
async function animateGlobeTrip(homeCentroid, destCentroid, destCode) {
|
||||
const interp = d3.geoInterpolate(homeCentroid, destCentroid);
|
||||
const steps = 80;
|
||||
const geoPts = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
geoPts.push(interp(i / steps));
|
||||
}
|
||||
|
||||
const dist = d3.geoDistance(homeCentroid, destCentroid);
|
||||
const dur = Math.round(1500 + dist * 2500);
|
||||
|
||||
const lineGen = d3.line().curve(d3.curveBasis);
|
||||
|
||||
const outEl = gAnim.append('path')
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', ARC_COLOR)
|
||||
.attr('stroke-width', 2.5)
|
||||
.attr('stroke-opacity', 0.8)
|
||||
.attr('stroke-linecap', 'round');
|
||||
const outTip = gAnim.append('path')
|
||||
.attr('d', PLANE_PATH)
|
||||
.attr('fill', PLANE_COLOR)
|
||||
.attr('opacity', 0);
|
||||
|
||||
await Promise.all([
|
||||
rotateGlobeTo(destCentroid[0], destCentroid[1], dur),
|
||||
animateReprojectingArc(outEl, outTip, geoPts, lineGen, dur)
|
||||
]);
|
||||
if (isCancelled) return;
|
||||
|
||||
outEl.remove();
|
||||
outTip.remove();
|
||||
|
||||
const targetPath = countryPaths.filter(d => effId(d) === destCode);
|
||||
targetPath.transition().duration(500).attr('fill', VISITED_COLOR);
|
||||
visitedCodes.add(destCode);
|
||||
|
||||
await delay(600);
|
||||
if (isCancelled) return;
|
||||
|
||||
const revGeoPts = [...geoPts].reverse();
|
||||
|
||||
const retEl = gAnim.append('path')
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', ARC_COLOR)
|
||||
.attr('stroke-width', 2.5)
|
||||
.attr('stroke-opacity', 0.8)
|
||||
.attr('stroke-linecap', 'round');
|
||||
const retTip = gAnim.append('path')
|
||||
.attr('d', PLANE_PATH)
|
||||
.attr('fill', PLANE_COLOR)
|
||||
.attr('opacity', 0);
|
||||
|
||||
await Promise.all([
|
||||
rotateGlobeTo(homeCentroid[0], homeCentroid[1], dur),
|
||||
animateReprojectingArc(retEl, retTip, revGeoPts, lineGen, dur)
|
||||
]);
|
||||
if (isCancelled) return;
|
||||
|
||||
retEl.remove();
|
||||
retTip.remove();
|
||||
|
||||
await delay(300);
|
||||
}
|
||||
|
||||
async function startJourney() {
|
||||
const myId = ++animId;
|
||||
isPlaying = true;
|
||||
isFinished = false;
|
||||
isCancelled = false;
|
||||
visitedCodes = new Set();
|
||||
|
||||
const width = frameEl.clientWidth;
|
||||
const height = frameEl.clientHeight;
|
||||
|
||||
svg.selectAll('*').remove();
|
||||
gBase = svg.append('g');
|
||||
gCountries = svg.append('g');
|
||||
gAnim = svg.append('g');
|
||||
|
||||
setupProjection(width, height);
|
||||
|
||||
if (mode === 'globe' && homeFeature) {
|
||||
const c = d3.geoCentroid(homeFeature);
|
||||
projection.rotate([-c[0], -c[1]]);
|
||||
pathFn = d3.geoPath().projection(projection);
|
||||
}
|
||||
|
||||
renderMap();
|
||||
|
||||
const trips = MOCK_TRIPS;
|
||||
const total = trips.length;
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
if (isCancelled) break;
|
||||
if (isCancelled || myId !== animId) break;
|
||||
|
||||
const trip = trips[i];
|
||||
currentDateLabel = formatDateLabel(trip.date);
|
||||
const destFeature = featuresById[trip.countryCode];
|
||||
if (!destFeature) continue;
|
||||
|
||||
@@ -232,11 +479,11 @@
|
||||
await animateTrip(trip.countryCode, destFeature);
|
||||
}
|
||||
|
||||
if (!isCancelled) {
|
||||
if (!isCancelled && myId === animId) {
|
||||
isFinished = true;
|
||||
isPlaying = false;
|
||||
if (onprogress) onprogress({ index: trips.length, total: trips.length, label: 'Journey complete!' });
|
||||
} else {
|
||||
} else if (myId === animId) {
|
||||
isPlaying = false;
|
||||
}
|
||||
}
|
||||
@@ -246,23 +493,37 @@
|
||||
isPlaying = false;
|
||||
}
|
||||
|
||||
function replay() {
|
||||
stopJourney();
|
||||
setTimeout(() => startJourney(), 100);
|
||||
}
|
||||
|
||||
function switchMode(target) {
|
||||
if (target === mode) return;
|
||||
onmodechange?.(target);
|
||||
stopJourney();
|
||||
setTimeout(() => startJourney(), 100);
|
||||
}
|
||||
|
||||
function close() {
|
||||
stopJourney();
|
||||
onclose?.();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const width = frameEl.clientWidth;
|
||||
const height = frameEl.clientHeight;
|
||||
|
||||
projection = d3.geoMercator();
|
||||
fitProjection(projection, width, height);
|
||||
setupProjection(width, height);
|
||||
|
||||
pathFn = d3.geoPath().projection(projection);
|
||||
|
||||
const countries = feature(worldData, worldData.objects.countries)
|
||||
countriesData = feature(worldData, worldData.objects.countries)
|
||||
.features.filter(f => (f.id || f.properties.name === 'Kosovo') && f.id !== '010');
|
||||
|
||||
countries.forEach(f => {
|
||||
countriesData.forEach(f => {
|
||||
if (!f.id) f.id = 'XK';
|
||||
});
|
||||
|
||||
for (const f of countries) {
|
||||
for (const f of countriesData) {
|
||||
featuresById[effId(f)] = f;
|
||||
}
|
||||
|
||||
@@ -274,46 +535,32 @@
|
||||
.attr('height', height)
|
||||
.style('cursor', 'default');
|
||||
|
||||
g = svg.append('g');
|
||||
gBase = svg.append('g');
|
||||
gCountries = svg.append('g');
|
||||
gAnim = svg.append('g');
|
||||
|
||||
countryPaths = g.selectAll('path')
|
||||
.data(countries)
|
||||
.join('path')
|
||||
.attr('d', pathFn)
|
||||
.attr('fill', d => effId(d) === HOME_CODE ? HOME_COLOR : UNVISITED)
|
||||
.attr('stroke', '#d4d4d4')
|
||||
.attr('stroke-width', 0.5);
|
||||
|
||||
function renderMicrostates() {
|
||||
g.selectAll('.micro-state-j').remove();
|
||||
const threshold = 4;
|
||||
countryPaths.each(function (d) {
|
||||
if (effId(d) !== d.id) return;
|
||||
const { width, height } = this.getBBox();
|
||||
if (width < threshold && height < threshold) {
|
||||
const [cx, cy] = pathFn.centroid(d);
|
||||
g.append('circle')
|
||||
.attr('class', 'micro-state-j')
|
||||
.datum(d)
|
||||
.attr('cx', cx)
|
||||
.attr('cy', cy)
|
||||
.attr('r', 2)
|
||||
.attr('fill', effId(d) === HOME_CODE ? HOME_COLOR : UNVISITED)
|
||||
.attr('stroke', '#94a3b8')
|
||||
.attr('stroke-width', 0.5);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderMicrostates();
|
||||
renderMap();
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
svg.attr('width', width).attr('height', height);
|
||||
fitProjection(projection, width, height);
|
||||
countryPaths.attr('d', pathFn);
|
||||
renderMicrostates();
|
||||
const prevProj = projection;
|
||||
if (mode === 'map') {
|
||||
projection = d3.geoMercator();
|
||||
projection.fitSize([width, height], { type: 'Sphere' });
|
||||
const s = projection.scale() * 1.5;
|
||||
projection.scale(s).translate([width / 2, height * 0.70]);
|
||||
} else {
|
||||
const size = Math.min(width, height) * 0.92;
|
||||
projection = d3.geoOrthographic()
|
||||
.rotate(prevProj.rotate())
|
||||
.fitSize([size, size], { type: 'Sphere' })
|
||||
.translate([width / 2, height / 2]);
|
||||
}
|
||||
pathFn = d3.geoPath().projection(projection);
|
||||
redrawBase();
|
||||
if (mode === 'map') renderMicrostates();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -329,12 +576,26 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={frameEl} class="journey-frame">
|
||||
<button class="close-btn" onclick={() => { stopJourney(); onclose?.(); }}>✕</button>
|
||||
<div bind:this={frameEl} class="journey-frame" class:globe-mode={mode === 'globe'}>
|
||||
<div class="top-label">
|
||||
{#if isFinished}
|
||||
<div class="done-badge">Journey complete!</div>
|
||||
Journey complete!
|
||||
{:else if currentDateLabel}
|
||||
{currentDateLabel}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="control-bar">
|
||||
<button class="control-btn" onclick={replay}>
|
||||
⟳ Replay
|
||||
</button>
|
||||
<button class="control-btn" onclick={() => switchMode(mode === 'map' ? 'globe' : 'map')}>
|
||||
{mode === 'map' ? 'Globe animation' : 'Map animation'}
|
||||
</button>
|
||||
<button class="control-btn" onclick={close}>
|
||||
✕ Back to Journaling
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.journey-frame {
|
||||
@@ -345,47 +606,59 @@
|
||||
background: #a4c8e0;
|
||||
}
|
||||
|
||||
.journey-frame.globe-mode {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.journey-frame :global(svg) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
.top-label {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0,0,0,0.55);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: rgba(0,0,0,0.75);
|
||||
}
|
||||
|
||||
.done-badge {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
z-index: 10;
|
||||
background: rgba(0,0,0,0.65);
|
||||
color: #fff;
|
||||
font-family: var(--heading, sans-serif);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 10px 24px;
|
||||
border-radius: 24px;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.04em;
|
||||
min-width: 200px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.control-bar {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
background: #8b5cf6;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: #7c3aed;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user