Enhance CountryMap component with zoom and recenter functionality, and add map controls for improved user interaction

This commit is contained in:
sHa
2025-08-14 02:05:49 +03:00
parent 6c7ac29f62
commit 1e722252e2
2 changed files with 159 additions and 11 deletions

View File

@@ -165,7 +165,11 @@
</div> </div>
<div class="right-column"> <div class="right-column">
{#if logo.tags && logo.tags.some((tagObj) => (tagObj.text || tagObj) === "Country") && logo.meta && logo.meta["ISO code"]} {#if logo.tags && logo.tags.some((tagObj) => (tagObj.text || tagObj) === "Country") && logo.meta && logo.meta["ISO code"]}
<CountryMap countryCodes={[logo.meta["ISO code"]]} countryNames={[logo.meta["country"]]} /> <CountryMap
countryCodes={[logo.meta["ISO code"]]}
countryNames={[logo.meta["country"]]}
countryScale={true}
/>
{/if} {/if}
<div class="logo-details fullscreen-details"> <div class="logo-details fullscreen-details">
@@ -510,5 +514,4 @@
.logo-details span { .logo-details span {
color: var(--color-text); color: var(--color-text);
} }
</style> </style>

View File

@@ -4,8 +4,40 @@
export let countryCodes = []; export let countryCodes = [];
export let countryNames = []; export let countryNames = [];
export let mapPath = "/data/world.svg"; export let mapPath = "/data/world.svg";
export let countryScale = false;
export let scalePadding = 30;
let wrapperRef; let wrapperRef;
// --- Zoom and recenter logic ---
let lastViewBox = null;
function getSvgEl() {
return wrapperRef ? wrapperRef.querySelector("svg") : null;
}
function zoom(factor) {
const svgEl = getSvgEl();
if (!svgEl) return;
const vb = svgEl.getAttribute("viewBox");
if (!vb) return;
let [x, y, w, h] = vb.split(" ").map(Number);
const cx = x + w / 2;
const cy = y + h / 2;
w /= factor;
h /= factor;
x = cx - w / 2;
y = cy - h / 2;
svgEl.setAttribute("viewBox", `${x} ${y} ${w} ${h}`);
lastViewBox = `${x} ${y} ${w} ${h}`;
}
function zoomIn() {
zoom(1.2);
}
function zoomOut() {
zoom(0.8);
}
function recenter() {
highlightCountries();
}
// Highlight countries after SVG loads, using MutationObserver for reliability // Highlight countries after SVG loads, using MutationObserver for reliability
let observer; let observer;
function highlightCountries() { function highlightCountries() {
@@ -13,15 +45,17 @@
const svgEl = wrapperRef.querySelector("svg"); const svgEl = wrapperRef.querySelector("svg");
if (!svgEl) return; if (!svgEl) return;
// Highlight by country code (id) // Highlight by country code (id)
let highlighted = [];
countryCodes.forEach((code) => { countryCodes.forEach((code) => {
const countryPath = svgEl.querySelector(`#${code}`); const countryPath = svgEl.querySelector(`#${code}`);
if (countryPath) { if (countryPath) {
countryPath.setAttribute("fill", "#4f8cff"); countryPath.setAttribute("fill", "#4f8cff");
countryPath.setAttribute("stroke", "#222"); countryPath.setAttribute("stroke", "#222");
countryPath.style.filter = "drop-shadow(0 0 4px #4f8cff44)"; countryPath.style.filter = "drop-shadow(0 0 4px #4f8cff44)";
highlighted.push(countryPath);
} }
}); });
// Highlight by country name (name attribute) // Highlight by country name (name attribute or class)
const names = Array.isArray(countryNames) const names = Array.isArray(countryNames)
? countryNames ? countryNames
: countryNames : countryNames
@@ -37,8 +71,89 @@
countryPath.setAttribute("fill", "#4f8cff"); countryPath.setAttribute("fill", "#4f8cff");
countryPath.setAttribute("stroke", "#222"); countryPath.setAttribute("stroke", "#222");
countryPath.style.filter = "drop-shadow(0 0 4px #4f8cff44)"; countryPath.style.filter = "drop-shadow(0 0 4px #4f8cff44)";
highlighted.push(countryPath);
}); });
}); });
// Smart scale/center if enabled and at least one country is highlighted
if (countryScale && highlighted.length > 0) {
// Compute bounding box of all highlighted paths
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
highlighted.forEach((path) => {
let bbox;
try {
bbox = path.getBBox();
} catch (e) {
return;
}
if (bbox.x < minX) minX = bbox.x;
if (bbox.y < minY) minY = bbox.y;
if (bbox.x + bbox.width > maxX) maxX = bbox.x + bbox.width;
if (bbox.y + bbox.height > maxY) maxY = bbox.y + bbox.height;
});
// Center and scale the SVG viewBox to the highlighted country
if (
isFinite(minX) &&
isFinite(minY) &&
isFinite(maxX) &&
isFinite(maxY)
) {
minX -= scalePadding;
minY -= scalePadding;
maxX += scalePadding;
maxY += scalePadding;
const width = maxX - minX;
const height = maxY - minY;
svgEl.setAttribute(
"viewBox",
`${minX} ${minY} ${width} ${height}`,
);
}
}
}
// --- Map drag/move by mouse ---
let isDragging = false;
let dragStart = { x: 0, y: 0 };
let viewBoxStart = null;
function onMouseDown(e) {
const svgEl = wrapperRef.querySelector("svg");
if (!svgEl) return;
isDragging = true;
dragStart = { x: e.clientX, y: e.clientY };
const vb = svgEl.getAttribute("viewBox");
if (vb) {
const [x, y, w, h] = vb.split(" ").map(Number);
viewBoxStart = { x, y, w, h };
}
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
}
function onMouseMove(e) {
if (!isDragging || !viewBoxStart) return;
const svgEl = wrapperRef.querySelector("svg");
if (!svgEl) return;
const dx = e.clientX - dragStart.x;
const dy = e.clientY - dragStart.y;
// Scale drag to SVG units
const scaleX = viewBoxStart.w / wrapperRef.offsetWidth;
const scaleY = viewBoxStart.h / wrapperRef.offsetHeight;
const newX = viewBoxStart.x - dx * scaleX;
const newY = viewBoxStart.y - dy * scaleY;
svgEl.setAttribute(
"viewBox",
`${newX} ${newY} ${viewBoxStart.w} ${viewBoxStart.h}`,
);
}
function onMouseUp() {
isDragging = false;
viewBoxStart = null;
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
} }
function observeSvg() { function observeSvg() {
@@ -65,8 +180,15 @@
</script> </script>
<div class="country-map-section"> <div class="country-map-section">
<div class="svg-wrapper" bind:this={wrapperRef}> <div bind:this={wrapperRef} style="width:100%;height:100%;position:relative;">
<InlineSvg path={mapPath} alt="World map" color={undefined} /> <InlineSvg path={mapPath} alt="World map" color={undefined} />
{#if countryScale}
<div class="map-controls-on-map">
<button class="zoom-btn-on-map" on:click={zoomIn}>+</button>
<button class="zoom-btn-on-map" on:click={recenter}>⦿</button>
<button class="zoom-btn-on-map" on:click={zoomOut}>-</button>
</div>
{/if}
</div> </div>
</div> </div>
@@ -77,12 +199,35 @@
padding: 1rem; padding: 1rem;
box-shadow: 0 2px 8px 2px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 8px 2px rgba(0, 0, 0, 0.08);
} }
.svg-wrapper {
width: 100%; .map-controls-on-map {
height: 180px; position: absolute;
margin: 0 auto; right: 0.5em;
background: var(--color-bg); bottom: 0.5em;
border-radius: 8px; display: flex;
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.04); flex-direction: column;
gap: 0.5em;
z-index: 10;
}
.zoom-btn-on-map {
background: var(--color-accent, #222);
color: var(--color-bg, #4f8cff);
border: 1px solid var(--color-border, #222);
border-radius: 50%;
width: 20px;
height: 20px;
font-size: 1em;
cursor: pointer;
box-shadow: 0 2px 8px 2px rgba(0,0,0,0.08);
transition: background 0.2s, color 0.2s;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
padding: 0;
}
.zoom-btn-on-map:hover {
background: var(--color-bg, #4f8cff);
color: var(--color-text, #fff);
} }
</style> </style>