Verbesserungen kategoireansicht, karte aof ortsansicht

This commit is contained in:
Simon Martens
2025-09-28 17:34:45 +02:00
parent 2318c0c06d
commit 3b19ea94b6
5 changed files with 526 additions and 282 deletions

View File

@@ -596,10 +596,10 @@ export class PlacesMap extends HTMLElement {
});
},
{
// Trigger when element enters viewport
threshold: 0.1,
// No root margin for precise detection
rootMargin: "0px",
// Trigger when any part enters viewport - better for small elements
threshold: 0,
// Add some margin to trigger slightly before/after entering viewport
rootMargin: "10px 0px",
},
);
@@ -608,20 +608,6 @@ export class PlacesMap extends HTMLElement {
this.intersectionObserver.observe(container);
});
// Force an immediate check by triggering a scroll event
setTimeout(() => {
// Manually trigger intersection calculation
placeContainers.forEach((container) => {
const rect = container.getBoundingClientRect();
const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
const placeId = container.getAttribute("data-place-id");
const mapPoint = this.mapPoints.get(placeId);
if (mapPoint && isVisible) {
this.setPointActive(mapPoint);
}
});
}, 50);
}
setPointActive(point) {
@@ -646,6 +632,7 @@ export class PlacesMap extends HTMLElement {
return;
}
if (this.tooltip && tooltipText) {
// Set tooltip content and position
this.tooltip.textContent = tooltipText;
@@ -784,63 +771,18 @@ export class PlacesMap extends HTMLElement {
// Track the currently hovered place ID
this.currentHoveredPlaceId = placeId;
// Give the point a subtle highlight by making it larger immediately
// Give the point a more visible highlight by making it larger immediately
mapPoint.classList.remove("w-1", "h-1", "w-1.5", "h-1.5");
mapPoint.classList.add("w-2", "h-2");
mapPoint.classList.add("w-2.5", "h-2.5");
mapPoint.style.zIndex = "25";
// Don't show NEW tooltip if scrolling blocked (behavior 4)
if (this.isNewPopupBlocked(placeId)) {
return;
}
const tooltipText = mapPoint.dataset.tooltipText;
if (this.tooltip && tooltipText) {
// Set tooltip content and position
this.tooltip.textContent = tooltipText;
// Position tooltip at the map point location
const pointRect = mapPoint.getBoundingClientRect();
const mapRect = this.mapElement.getBoundingClientRect();
const x = pointRect.left - mapRect.left + pointRect.width / 2;
const y = pointRect.top - mapRect.top + pointRect.height / 2;
this.tooltip.style.left = `${x}px`;
this.tooltip.style.top = `${y}px`;
// Clear any existing timeouts
this.clearTimeouts();
if (this.isTooltipVisible) {
// Behavior 2b: Popup was visible -- instant popup
this.tooltip.classList.remove("opacity-0");
this.tooltip.classList.add("opacity-100");
} else {
// Behavior 2a: No popup was visible -- Popup after 150ms
this.showTimeout = setTimeout(() => {
this.tooltip.classList.remove("opacity-0");
this.tooltip.classList.add("opacity-100");
this.isTooltipVisible = true;
}, 150);
}
}
// No tooltip when hovering over place titles - only visual feedback
} else if (action === "hide") {
// Clear the currently hovered place ID
this.currentHoveredPlaceId = "";
// Behavior 3: Leave tile or map point -- close after 150ms delay
// NOTE: This is NOT blocked during scroll - existing popup should close when mouse leaves
this.clearTimeouts();
this.hideTimeout = setTimeout(() => {
if (this.tooltip) {
this.tooltip.classList.remove("opacity-100");
this.tooltip.classList.add("opacity-0");
this.isTooltipVisible = false;
}
}, 150);
// Remove point highlight - restore original size based on current state
mapPoint.classList.remove("w-2", "h-2");
mapPoint.classList.remove("w-2.5", "h-2.5");
// Check if this point is currently active or inactive
if (mapPoint.className.includes("bg-red-500")) {
// Active point
@@ -872,7 +814,229 @@ export class PlacesMap extends HTMLElement {
}
}
/**
* Places Map Single Web Component
* Embeds an SVG map with a single highlighted place
*/
export class PlacesMapSingle extends HTMLElement {
constructor() {
super();
this.place = null;
this.mapElement = null;
this.pointsContainer = null;
this.tooltip = null;
}
connectedCallback() {
this.parseData();
this.render();
this.initializeMap();
}
parseData() {
try {
const placeData = this.dataset.place;
if (placeData) {
this.place = JSON.parse(placeData);
}
} catch (error) {
console.error("Failed to parse place data:", error);
this.place = null;
}
}
render() {
this.innerHTML = `
<div class="map-container relative w-full aspect-[5/7] overflow-hidden bg-slate-100">
<div class="transform-wrapper absolute top-0 left-0 w-full h-auto origin-top-left">
<img src="/assets/Europe.svg" alt="Map of Europe" class="block w-full h-auto">
<div class="points-container absolute top-0 left-0 w-full h-full"></div>
</div>
<div class="absolute bottom-0 right-0 h-auto text-[0.6rem] bg-white px-0.5 bg-opacity-[0.5] border">
<i class="ri-creative-commons-line"></i>
<a href="https://commons.wikimedia.org/wiki/File:Europe_laea_topography.svg" target="_blank" class="">
Wikimedia Commons
</a>
</div>
<!-- Tooltip -->
<div class="map-tooltip absolute bg-slate-800 text-white text-sm px-2 py-1 rounded shadow-lg pointer-events-none opacity-0 transition-opacity duration-200 z-30 whitespace-nowrap" style="transform: translate(-50%, -100%); margin-top: -8px;"></div>
</div>
`;
this.mapElement = this.querySelector(".map-container");
this.pointsContainer = this.querySelector(".points-container");
this.tooltip = this.querySelector(".map-tooltip");
}
initializeMap() {
if (!this.place || !this.place.lat || !this.place.lng || !this.pointsContainer) {
return;
}
// Map extent constants
const MAP_EXTENT_METERS = { xmin: 2555000, ymin: 1350000, xmax: 7405000, ymax: 5500000 };
const PROJECTION_CENTER = { lon: 10, lat: 52 };
// Convert lat/lng to % calculation
const convertLatLngToPercent = (lat, lng) => {
const R = 6371000;
const FE = 4321000;
const FN = 3210000;
const lon_0_rad = (PROJECTION_CENTER.lon * Math.PI) / 180;
const lat_0_rad = (PROJECTION_CENTER.lat * Math.PI) / 180;
const lon_rad = (lng * Math.PI) / 180;
const lat_rad = (lat * Math.PI) / 180;
const k_prime = Math.sqrt(
2 /
(1 +
Math.sin(lat_0_rad) * Math.sin(lat_rad) +
Math.cos(lat_0_rad) * Math.cos(lat_rad) * Math.cos(lon_rad - lon_0_rad)),
);
const x_proj = R * k_prime * Math.cos(lat_rad) * Math.sin(lon_rad - lon_0_rad);
const y_proj =
R *
k_prime *
(Math.cos(lat_0_rad) * Math.sin(lat_rad) -
Math.sin(lat_0_rad) * Math.cos(lat_rad) * Math.cos(lon_rad - lon_0_rad));
const finalX = x_proj + FE;
const finalY = y_proj + FN;
const mapWidthMeters = MAP_EXTENT_METERS.xmax - MAP_EXTENT_METERS.xmin;
const mapHeightMeters = MAP_EXTENT_METERS.ymax - MAP_EXTENT_METERS.ymin;
const xPercent = ((finalX - MAP_EXTENT_METERS.xmin) / mapWidthMeters) * 100;
const yPercent = ((MAP_EXTENT_METERS.ymax - finalY) / mapHeightMeters) * 100;
return { x: xPercent, y: yPercent };
};
const lat = parseFloat(this.place.lat);
const lng = parseFloat(this.place.lng);
const position = convertLatLngToPercent(lat, lng);
// Only add point if it's within the visible map area
if (position.x >= 0 && position.x <= 100 && position.y >= 0 && position.y <= 100) {
const point = document.createElement("div");
point.style.left = `${position.x}%`;
point.style.top = `${position.y}%`;
point.style.transformOrigin = "center";
// Single highlighted point - moderately sized for zoomed view
point.className = "absolute w-2 h-2 bg-red-500 border border-red-700 rounded-full shadow-md -translate-x-1/2 -translate-y-1/2 z-20";
const tooltipText = `${this.place.name}${this.place.toponymName && this.place.toponymName !== this.place.name ? ` (${this.place.toponymName})` : ""}`;
point.dataset.tooltipText = tooltipText;
// Add hover event listeners
point.addEventListener("mouseenter", (e) => this.showTooltip(e));
point.addEventListener("mouseleave", () => this.hideTooltip());
point.addEventListener("mousemove", (e) => this.updateTooltipPosition(e));
this.pointsContainer.appendChild(point);
// Auto-zoom to the single point with some padding
this.autoZoomToPoint(position);
}
}
autoZoomToPoint(position) {
// Create a balanced area around the point for zooming
const PADDING = 20; // 20% padding around the point
let minX = Math.max(0, position.x - PADDING);
let maxX = Math.min(100, position.x + PADDING);
let minY = Math.max(0, position.y - PADDING);
let maxY = Math.min(100, position.y + PADDING);
let width = maxX - minX;
let height = maxY - minY;
// Maintain 5:7 aspect ratio
const ASPECT_RATIO = 5 / 7;
const pointsAspectRatio = width / height;
let finalViewBox = { x: minX, y: minY, width: width, height: height };
if (pointsAspectRatio > ASPECT_RATIO) {
const newTargetHeight = width / ASPECT_RATIO;
finalViewBox.y = minY - (newTargetHeight - height) / 2;
finalViewBox.height = newTargetHeight;
} else {
const newTargetWidth = height * ASPECT_RATIO;
finalViewBox.x = minX - (newTargetWidth - width) / 2;
finalViewBox.width = newTargetWidth;
}
// Ensure the final viewbox stays within map boundaries (0-100%)
if (finalViewBox.x < 0) {
finalViewBox.width += finalViewBox.x;
finalViewBox.x = 0;
}
if (finalViewBox.y < 0) {
finalViewBox.height += finalViewBox.y;
finalViewBox.y = 0;
}
if (finalViewBox.x + finalViewBox.width > 100) {
finalViewBox.width = 100 - finalViewBox.x;
}
if (finalViewBox.y + finalViewBox.height > 100) {
finalViewBox.height = 100 - finalViewBox.y;
}
// Ensure minimum zoom level - don't zoom in too much if constrained by boundaries
const MIN_VIEWPORT_SIZE = 30; // Minimum 30% of map should be visible
if (finalViewBox.width < MIN_VIEWPORT_SIZE) {
const expansion = (MIN_VIEWPORT_SIZE - finalViewBox.width) / 2;
finalViewBox.x = Math.max(0, finalViewBox.x - expansion);
finalViewBox.width = Math.min(MIN_VIEWPORT_SIZE, 100 - finalViewBox.x);
}
if (finalViewBox.height < MIN_VIEWPORT_SIZE) {
const expansion = (MIN_VIEWPORT_SIZE - finalViewBox.height) / 2;
finalViewBox.y = Math.max(0, finalViewBox.y - expansion);
finalViewBox.height = Math.min(MIN_VIEWPORT_SIZE, 100 - finalViewBox.y);
}
// Apply transformation
const scale = 100 / finalViewBox.width;
const translateX = -finalViewBox.x;
const translateY = -finalViewBox.y;
const transformValue = `scale(${scale}) translate(${translateX}%, ${translateY}%)`;
const transformWrapper = this.querySelector(".transform-wrapper");
if (transformWrapper) {
transformWrapper.style.transform = transformValue;
}
}
showTooltip(event) {
const point = event.target;
const tooltipText = point.dataset.tooltipText;
if (this.tooltip && tooltipText) {
this.tooltip.textContent = tooltipText;
this.updateTooltipPosition(event);
this.tooltip.classList.remove("opacity-0");
this.tooltip.classList.add("opacity-100");
}
}
hideTooltip() {
if (this.tooltip) {
this.tooltip.classList.remove("opacity-100");
this.tooltip.classList.add("opacity-0");
}
}
updateTooltipPosition(event) {
if (!this.tooltip) return;
const mapRect = this.mapElement.getBoundingClientRect();
const x = event.clientX - mapRect.left;
const y = event.clientY - mapRect.top;
this.tooltip.style.left = `${x}px`;
this.tooltip.style.top = `${y}px`;
}
}
// Register the custom elements
customElements.define("places-filter", PlacesFilter);
customElements.define("place-accordion", PlaceAccordion);
customElements.define("places-map", PlacesMap);
customElements.define("places-map-single", PlacesMapSingle);