feat: add weather markers on map with special blue pill styling
- Weather posts display as compact blue pills with emoji + temperature - Weather posts forced to 'simple' type (never full card) to avoid duplicates - Parse temperature from title when not available in data - Z-index set to 10 (just above regular posts, below UI elements) - Add fetchWeatherPosts and fetchMapPins API functions - Add Weather modal components (WeatherModal, HourlyForecast, DailyForecast) - Add DayNightTerminator component for map 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ced3a5b1a0
commit
e8c2a4cda1
|
|
@ -209,7 +209,8 @@ export default function App() {
|
||||||
<footer className="sw-footer">
|
<footer className="sw-footer">
|
||||||
<div className="sw-footer-inner">
|
<div className="sw-footer-inner">
|
||||||
<div className="sw-footer-brand">SOCIOWIRE</div>
|
<div className="sw-footer-brand">SOCIOWIRE</div>
|
||||||
<div className="sw-footer-brand">© Sociowire v0.0008</div>
|
<div className="sw-footer-brand">© Sociowire v0.0009</div>
|
||||||
|
<div id="sw-weather-debug" style={{color: '#0ea5e9', fontSize: '10px'}}></div>
|
||||||
<nav className="sw-footer-nav" aria-label="Site">
|
<nav className="sw-footer-nav" aria-label="Site">
|
||||||
<a href="/">{t('nav.home')}</a>
|
<a href="/">{t('nav.home')}</a>
|
||||||
<a href="/#map">{t('nav.liveMap')}</a>
|
<a href="/#map">{t('nav.liveMap')}</a>
|
||||||
|
|
|
||||||
|
|
@ -1014,3 +1014,32 @@ export function getBrowserTimezone() {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Weather API
|
||||||
|
export async function fetchWeatherByCity(city) {
|
||||||
|
const url = `${apiBase()}/weather?city=${encodeURIComponent(city)}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWeatherByCoords(lat, lon) {
|
||||||
|
const url = `${apiBase()}/weather?lat=${lat}&lon=${lon}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWeatherByPostId(postId) {
|
||||||
|
const url = `${apiBase()}/weather?post_id=${postId}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWeatherPosts() {
|
||||||
|
const url = `${apiBase()}/weather-posts`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,343 @@
|
||||||
|
/**
|
||||||
|
* Day/Night Terminator Layer for MapLibre
|
||||||
|
* Shows a semi-transparent overlay for the night side of Earth
|
||||||
|
* Updates in real-time as the Earth rotates
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
// Calculate the position of the sun based on current time
|
||||||
|
function getSunPosition(date = new Date()) {
|
||||||
|
// Day of year
|
||||||
|
const start = new Date(date.getFullYear(), 0, 0);
|
||||||
|
const diff = date - start;
|
||||||
|
const oneDay = 1000 * 60 * 60 * 24;
|
||||||
|
const dayOfYear = Math.floor(diff / oneDay);
|
||||||
|
|
||||||
|
// Solar declination (angle of sun relative to equator)
|
||||||
|
// Simplified calculation
|
||||||
|
const declination = -23.45 * Math.cos((360 / 365) * (dayOfYear + 10) * (Math.PI / 180));
|
||||||
|
|
||||||
|
// Hour angle (where the sun is longitudinally)
|
||||||
|
const hours = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600;
|
||||||
|
const longitude = ((12 - hours) / 24) * 360;
|
||||||
|
|
||||||
|
return {
|
||||||
|
lat: declination,
|
||||||
|
lon: longitude,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate night polygon coordinates
|
||||||
|
// The terminator is the line between day and night
|
||||||
|
function generateNightPolygon(sunPos, segments = 360) {
|
||||||
|
const { lat: sunLat, lon: sunLon } = sunPos;
|
||||||
|
const coordinates = [];
|
||||||
|
|
||||||
|
// Generate terminator line points
|
||||||
|
for (let i = 0; i <= segments; i++) {
|
||||||
|
const angle = (i / segments) * 360;
|
||||||
|
const rad = angle * (Math.PI / 180);
|
||||||
|
const sunLatRad = sunLat * (Math.PI / 180);
|
||||||
|
|
||||||
|
// Calculate latitude where the terminator crosses this longitude
|
||||||
|
// The terminator is where the sun is exactly on the horizon
|
||||||
|
const terminatorLat = Math.atan(-Math.cos(rad) / Math.tan(sunLatRad)) * (180 / Math.PI);
|
||||||
|
|
||||||
|
let lng = sunLon + angle;
|
||||||
|
if (lng > 180) lng -= 360;
|
||||||
|
if (lng < -180) lng += 360;
|
||||||
|
|
||||||
|
coordinates.push([lng, terminatorLat]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the polygon by adding polar caps on the night side
|
||||||
|
// Determine which pole is in darkness based on sun position
|
||||||
|
const nightPoleSide = sunLat > 0 ? -90 : 90;
|
||||||
|
|
||||||
|
// Build the complete night polygon
|
||||||
|
const nightPoly = [];
|
||||||
|
|
||||||
|
// First half of terminator (going one direction)
|
||||||
|
for (let i = 0; i <= segments / 2; i++) {
|
||||||
|
nightPoly.push(coordinates[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go to the dark pole
|
||||||
|
const lastPt = coordinates[Math.floor(segments / 2)];
|
||||||
|
nightPoly.push([lastPt[0], nightPoleSide]);
|
||||||
|
|
||||||
|
// Follow the dark pole
|
||||||
|
const firstPt = coordinates[0];
|
||||||
|
nightPoly.push([firstPt[0], nightPoleSide]);
|
||||||
|
|
||||||
|
// Close back to start
|
||||||
|
nightPoly.push(coordinates[0]);
|
||||||
|
|
||||||
|
return nightPoly;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternative: generate a more accurate night polygon using spherical geometry
|
||||||
|
function generateNightPolygonAccurate(sunPos, segments = 180) {
|
||||||
|
const { lat: sunLat, lon: sunLon } = sunPos;
|
||||||
|
const sunLatRad = sunLat * (Math.PI / 180);
|
||||||
|
const sunLonRad = sunLon * (Math.PI / 180);
|
||||||
|
|
||||||
|
// Generate points along the terminator circle
|
||||||
|
const terminatorPoints = [];
|
||||||
|
for (let i = 0; i <= segments; i++) {
|
||||||
|
const angle = (i / segments) * 2 * Math.PI;
|
||||||
|
|
||||||
|
// Terminator is a great circle 90 degrees from the sub-solar point
|
||||||
|
const lat = Math.asin(
|
||||||
|
Math.sin(sunLatRad) * Math.cos(Math.PI / 2) +
|
||||||
|
Math.cos(sunLatRad) * Math.sin(Math.PI / 2) * Math.cos(angle)
|
||||||
|
);
|
||||||
|
|
||||||
|
const lon = sunLonRad + Math.atan2(
|
||||||
|
Math.sin(angle) * Math.sin(Math.PI / 2) * Math.cos(sunLatRad),
|
||||||
|
Math.cos(Math.PI / 2) - Math.sin(sunLatRad) * Math.sin(lat)
|
||||||
|
);
|
||||||
|
|
||||||
|
let lonDeg = lon * (180 / Math.PI);
|
||||||
|
if (lonDeg > 180) lonDeg -= 360;
|
||||||
|
if (lonDeg < -180) lonDeg += 360;
|
||||||
|
|
||||||
|
terminatorPoints.push([lonDeg, lat * (180 / Math.PI)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return terminatorPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a GeoJSON polygon for the night side
|
||||||
|
function buildNightGeoJSON(date = new Date()) {
|
||||||
|
const sunPos = getSunPosition(date);
|
||||||
|
|
||||||
|
// We'll create a simpler approach: a polygon covering the night hemisphere
|
||||||
|
// The night side is opposite to the sun
|
||||||
|
const nightLon = sunPos.lon + 180;
|
||||||
|
const segments = 180;
|
||||||
|
|
||||||
|
const coordinates = [];
|
||||||
|
|
||||||
|
// Generate the terminator curve
|
||||||
|
for (let i = 0; i <= segments; i++) {
|
||||||
|
const bearing = (i / segments) * 360;
|
||||||
|
const bearingRad = bearing * (Math.PI / 180);
|
||||||
|
const sunLatRad = sunPos.lat * (Math.PI / 180);
|
||||||
|
const sunLonRad = sunPos.lon * (Math.PI / 180);
|
||||||
|
|
||||||
|
// Point on the terminator (90 degrees from sub-solar point)
|
||||||
|
const angularDist = Math.PI / 2; // 90 degrees in radians
|
||||||
|
|
||||||
|
const lat = Math.asin(
|
||||||
|
Math.sin(sunLatRad) * Math.cos(angularDist) +
|
||||||
|
Math.cos(sunLatRad) * Math.sin(angularDist) * Math.cos(bearingRad)
|
||||||
|
);
|
||||||
|
|
||||||
|
const lon = sunLonRad + Math.atan2(
|
||||||
|
Math.sin(bearingRad) * Math.sin(angularDist) * Math.cos(sunLatRad),
|
||||||
|
Math.cos(angularDist) - Math.sin(sunLatRad) * Math.sin(lat)
|
||||||
|
);
|
||||||
|
|
||||||
|
let lonDeg = lon * (180 / Math.PI);
|
||||||
|
let latDeg = lat * (180 / Math.PI);
|
||||||
|
|
||||||
|
// Normalize longitude
|
||||||
|
while (lonDeg > 180) lonDeg -= 360;
|
||||||
|
while (lonDeg < -180) lonDeg += 360;
|
||||||
|
|
||||||
|
coordinates.push([lonDeg, latDeg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the polygon
|
||||||
|
coordinates.push(coordinates[0]);
|
||||||
|
|
||||||
|
// Create a polygon that covers the night side
|
||||||
|
// We need to determine which side of the terminator is night
|
||||||
|
// Night is the hemisphere opposite to the sun
|
||||||
|
const nightCenterLon = sunPos.lon > 0 ? sunPos.lon - 180 : sunPos.lon + 180;
|
||||||
|
const nightCenterLat = -sunPos.lat;
|
||||||
|
|
||||||
|
// Build polygon ring going around the terminator and back
|
||||||
|
// For MapLibre, we need a proper polygon. Let's use a simpler approach:
|
||||||
|
// Create a ring that goes around the dark hemisphere
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "Feature",
|
||||||
|
properties: {},
|
||||||
|
geometry: {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [coordinates],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simpler approach: create two polygons, one for each dark cap
|
||||||
|
function buildNightGeoJSONSimple(date = new Date()) {
|
||||||
|
const sunPos = getSunPosition(date);
|
||||||
|
const segments = 180;
|
||||||
|
|
||||||
|
// Generate terminator points
|
||||||
|
const terminatorPoints = [];
|
||||||
|
for (let i = 0; i <= segments; i++) {
|
||||||
|
const bearing = (i / segments) * 360;
|
||||||
|
const bearingRad = bearing * (Math.PI / 180);
|
||||||
|
const sunLatRad = sunPos.lat * (Math.PI / 180);
|
||||||
|
const sunLonRad = sunPos.lon * (Math.PI / 180);
|
||||||
|
const angularDist = Math.PI / 2;
|
||||||
|
|
||||||
|
const lat = Math.asin(
|
||||||
|
Math.sin(sunLatRad) * Math.cos(angularDist) +
|
||||||
|
Math.cos(sunLatRad) * Math.sin(angularDist) * Math.cos(bearingRad)
|
||||||
|
);
|
||||||
|
|
||||||
|
const lon = sunLonRad + Math.atan2(
|
||||||
|
Math.sin(bearingRad) * Math.sin(angularDist) * Math.cos(sunLatRad),
|
||||||
|
Math.cos(angularDist) - Math.sin(sunLatRad) * Math.sin(lat)
|
||||||
|
);
|
||||||
|
|
||||||
|
let lonDeg = lon * (180 / Math.PI);
|
||||||
|
let latDeg = lat * (180 / Math.PI);
|
||||||
|
|
||||||
|
while (lonDeg > 180) lonDeg -= 360;
|
||||||
|
while (lonDeg < -180) lonDeg += 360;
|
||||||
|
|
||||||
|
terminatorPoints.push([lonDeg, latDeg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort points by longitude for the polygon
|
||||||
|
const sortedPoints = [...terminatorPoints].sort((a, b) => a[0] - b[0]);
|
||||||
|
|
||||||
|
// Determine which pole is dark
|
||||||
|
// If sun is in northern hemisphere, south pole area is darker
|
||||||
|
// But we want the hemisphere opposite to the sun
|
||||||
|
const darkPole = sunPos.lat > 0 ? -89.9 : 89.9;
|
||||||
|
|
||||||
|
// Build night polygon: go along terminator, then to pole, back along -180/180
|
||||||
|
const nightRing = [];
|
||||||
|
|
||||||
|
// Add terminator points from west to east
|
||||||
|
for (const pt of sortedPoints) {
|
||||||
|
nightRing.push(pt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close by going to the dark pole and back
|
||||||
|
const lastPt = sortedPoints[sortedPoints.length - 1];
|
||||||
|
const firstPt = sortedPoints[0];
|
||||||
|
|
||||||
|
nightRing.push([180, lastPt[1]]);
|
||||||
|
nightRing.push([180, darkPole]);
|
||||||
|
nightRing.push([-180, darkPole]);
|
||||||
|
nightRing.push([-180, firstPt[1]]);
|
||||||
|
nightRing.push(firstPt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
properties: { side: "night" },
|
||||||
|
geometry: {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [nightRing],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DayNightTerminator({ map, enabled = true, opacity = 0.3 }) {
|
||||||
|
const sourceId = "day-night-source";
|
||||||
|
const layerId = "day-night-layer";
|
||||||
|
const updateIntervalRef = useRef(null);
|
||||||
|
|
||||||
|
const updateTerminator = useCallback(() => {
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const source = map.getSource(sourceId);
|
||||||
|
if (source) {
|
||||||
|
const geojson = buildNightGeoJSONSimple(new Date());
|
||||||
|
source.setData(geojson);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore errors if map is not ready
|
||||||
|
}
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map || !enabled) return;
|
||||||
|
|
||||||
|
// Wait for map to be ready
|
||||||
|
const addLayer = () => {
|
||||||
|
try {
|
||||||
|
// Check if source already exists
|
||||||
|
if (map.getSource(sourceId)) {
|
||||||
|
map.removeLayer(layerId);
|
||||||
|
map.removeSource(sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const geojson = buildNightGeoJSONSimple(new Date());
|
||||||
|
|
||||||
|
map.addSource(sourceId, {
|
||||||
|
type: "geojson",
|
||||||
|
data: geojson,
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: layerId,
|
||||||
|
type: "fill",
|
||||||
|
source: sourceId,
|
||||||
|
paint: {
|
||||||
|
"fill-color": "#000033",
|
||||||
|
"fill-opacity": opacity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update every minute
|
||||||
|
updateIntervalRef.current = setInterval(updateTerminator, 60000);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("DayNightTerminator: Failed to add layer", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (map.loaded()) {
|
||||||
|
addLayer();
|
||||||
|
} else {
|
||||||
|
map.once("load", addLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (updateIntervalRef.current) {
|
||||||
|
clearInterval(updateIntervalRef.current);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) {
|
||||||
|
map.removeLayer(layerId);
|
||||||
|
}
|
||||||
|
if (map.getSource(sourceId)) {
|
||||||
|
map.removeSource(sourceId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Map might be destroyed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [map, enabled, opacity, updateTerminator]);
|
||||||
|
|
||||||
|
// Update opacity when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map || !enabled) return;
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) {
|
||||||
|
map.setPaintProperty(layerId, "fill-opacity", opacity);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}, [map, enabled, opacity]);
|
||||||
|
|
||||||
|
return null; // This component doesn't render anything visible
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export helper for use in other components
|
||||||
|
export { getSunPosition, buildNightGeoJSONSimple };
|
||||||
|
|
@ -7,6 +7,7 @@ import "../../styles/filters.css";
|
||||||
|
|
||||||
import { createPost, fetchPostPreview, resolveAssetRef } from "../../api/client";
|
import { createPost, fetchPostPreview, resolveAssetRef } from "../../api/client";
|
||||||
import { LiveKitBroadcast, LiveKitViewerModal } from "../Live";
|
import { LiveKitBroadcast, LiveKitViewerModal } from "../Live";
|
||||||
|
import { WeatherModal } from "../Weather";
|
||||||
import {
|
import {
|
||||||
FILTER_MAIN_CATEGORIES,
|
FILTER_MAIN_CATEGORIES,
|
||||||
FILTER_CATEGORY_MAP,
|
FILTER_CATEGORY_MAP,
|
||||||
|
|
@ -23,6 +24,7 @@ import SmartSearchBar from "../Search/SmartSearchBar";
|
||||||
import UserProfile from "../Profile/UserProfile";
|
import UserProfile from "../Profile/UserProfile";
|
||||||
import { fetchPostById, fetchPostByUrl } from "../../api/client";
|
import { fetchPostById, fetchPostByUrl } from "../../api/client";
|
||||||
import { trackEvent } from "../../utils/analytics";
|
import { trackEvent } from "../../utils/analytics";
|
||||||
|
import DayNightTerminator from "./DayNightTerminator";
|
||||||
|
|
||||||
const CONTENT_CREATOR_MODES = [
|
const CONTENT_CREATOR_MODES = [
|
||||||
{
|
{
|
||||||
|
|
@ -142,6 +144,14 @@ export default function MapView({
|
||||||
});
|
});
|
||||||
const [subFilter, setSubFilter] = useState("All");
|
const [subFilter, setSubFilter] = useState("All");
|
||||||
const [mainNewsOnly, setMainNewsOnly] = useState(false);
|
const [mainNewsOnly, setMainNewsOnly] = useState(false);
|
||||||
|
const DAY_NIGHT_KEY = "sociowire:dayNight";
|
||||||
|
const [dayNightEnabled, setDayNightEnabled] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(DAY_NIGHT_KEY) === "true";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const debugEnabled = (() => {
|
const debugEnabled = (() => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -158,6 +168,12 @@ export default function MapView({
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [timeHours]);
|
}, [timeHours]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DAY_NIGHT_KEY, dayNightEnabled ? "true" : "false");
|
||||||
|
} catch {}
|
||||||
|
}, [dayNightEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (clusterFocus) setClusterFocus(null);
|
if (clusterFocus) setClusterFocus(null);
|
||||||
if (mainNewsOnly) setMainNewsOnly(false);
|
if (mainNewsOnly) setMainNewsOnly(false);
|
||||||
|
|
@ -180,6 +196,8 @@ export default function MapView({
|
||||||
const [showLiveBroadcast, setShowLiveBroadcast] = useState(false);
|
const [showLiveBroadcast, setShowLiveBroadcast] = useState(false);
|
||||||
const [activeLivePost, setActiveLivePost] = useState(null);
|
const [activeLivePost, setActiveLivePost] = useState(null);
|
||||||
const [liveCoords, setLiveCoords] = useState(null);
|
const [liveCoords, setLiveCoords] = useState(null);
|
||||||
|
// Weather modal state
|
||||||
|
const [activeWeatherPost, setActiveWeatherPost] = useState(null);
|
||||||
const openedPostRef = useRef(null);
|
const openedPostRef = useRef(null);
|
||||||
const queryPostRef = useRef(false);
|
const queryPostRef = useRef(false);
|
||||||
const overlayTimerRef = useRef(null);
|
const overlayTimerRef = useRef(null);
|
||||||
|
|
@ -931,6 +949,17 @@ export default function MapView({
|
||||||
return () => window.removeEventListener('livekit-open', onLiveOpen);
|
return () => window.removeEventListener('livekit-open', onLiveOpen);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Weather modal event listener
|
||||||
|
useEffect(() => {
|
||||||
|
const onWeatherOpen = (e) => {
|
||||||
|
if (e.detail) {
|
||||||
|
setActiveWeatherPost(e.detail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('weather-open', onWeatherOpen);
|
||||||
|
return () => window.removeEventListener('weather-open', onWeatherOpen);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleFlyToMe = () => {
|
const handleFlyToMe = () => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map || !userPosition) return;
|
if (!map || !userPosition) return;
|
||||||
|
|
@ -1692,6 +1721,7 @@ export default function MapView({
|
||||||
<div className="map-page">
|
<div className="map-page">
|
||||||
<div className="map-stage" ref={stageRef}>
|
<div className="map-stage" ref={stageRef}>
|
||||||
<div ref={containerRef} className="map-container" />
|
<div ref={containerRef} className="map-container" />
|
||||||
|
<DayNightTerminator map={mapRef.current} enabled={dayNightEnabled} opacity={0.3} />
|
||||||
{status && <div className="map-status">{status}</div>}
|
{status && <div className="map-status">{status}</div>}
|
||||||
{debugEnabled && (
|
{debugEnabled && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -1775,6 +1805,14 @@ export default function MapView({
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-umbrella-beach"></i>
|
<i className="fa-solid fa-umbrella-beach"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={"sw-search__iconBtn" + (dayNightEnabled ? " is-active" : "")}
|
||||||
|
title="Day/Night View"
|
||||||
|
onClick={() => setDayNightEnabled((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<i className={dayNightEnabled ? "fa-solid fa-moon" : "fa-solid fa-sun"}></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2073,6 +2111,16 @@ export default function MapView({
|
||||||
onClose={() => setActiveLivePost(null)}
|
onClose={() => setActiveLivePost(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeWeatherPost && (
|
||||||
|
<WeatherModal
|
||||||
|
postId={activeWeatherPost.id || activeWeatherPost.ID}
|
||||||
|
city={activeWeatherPost.city}
|
||||||
|
lat={activeWeatherPost.lat}
|
||||||
|
lon={activeWeatherPost.lon}
|
||||||
|
onClose={() => setActiveWeatherPost(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1111,6 +1111,14 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Weather posts open in a dedicated WeatherModal
|
||||||
|
if (post?.content_type === "weather") {
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(new CustomEvent("weather-open", { detail: post }));
|
||||||
|
} catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
closeCenteredOverlay(map, { force: true, reason: "switch", skipHistory: true });
|
closeCenteredOverlay(map, { force: true, reason: "switch", skipHistory: true });
|
||||||
|
|
||||||
const interactionState = {
|
const interactionState = {
|
||||||
|
|
@ -2156,6 +2164,135 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weather marker - shows temperature and weather icon directly on map
|
||||||
|
*/
|
||||||
|
const WEATHER_ICONS = {
|
||||||
|
sun: "☀️", moon: "🌙", "cloud-sun": "⛅", "cloud-moon": "☁️",
|
||||||
|
cloud: "☁️", smog: "🌫️", "cloud-rain": "🌧️", "cloud-showers-heavy": "🌧️",
|
||||||
|
snowflake: "❄️", "cloud-sun-rain": "🌦️", "cloud-meatball": "🌨️",
|
||||||
|
bolt: "⛈️", "temperature-half": "🌡️"
|
||||||
|
};
|
||||||
|
|
||||||
|
function weatherCodeToIcon(code, isDay = true) {
|
||||||
|
if (code === 0) return isDay ? "sun" : "moon";
|
||||||
|
if (code <= 2) return isDay ? "cloud-sun" : "cloud-moon";
|
||||||
|
if (code === 3) return "cloud";
|
||||||
|
if (code <= 48) return "smog";
|
||||||
|
if (code <= 67) return "cloud-rain";
|
||||||
|
if (code <= 77) return "snowflake";
|
||||||
|
if (code <= 82) return "cloud-sun-rain";
|
||||||
|
if (code <= 86) return "cloud-meatball";
|
||||||
|
if (code <= 99) return "bolt";
|
||||||
|
return "temperature-half";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWeatherFromTitle(title) {
|
||||||
|
// Parse "☀️ Montreal: 5°C - Clear sky" or "⛅ Paris: 12°C - Partly cloudy"
|
||||||
|
const match = title?.match(/:\s*(-?\d+)°C/);
|
||||||
|
const temp = match ? parseInt(match[1], 10) : null;
|
||||||
|
|
||||||
|
// Try to get city name
|
||||||
|
const cityMatch = title?.match(/^[^\w]*([A-Za-z\s]+):/);
|
||||||
|
const city = cityMatch ? cityMatch[1].trim() : "";
|
||||||
|
|
||||||
|
// Detect weather from emoji
|
||||||
|
let icon = "temperature-half";
|
||||||
|
if (title?.includes("☀️")) icon = "sun";
|
||||||
|
else if (title?.includes("🌙")) icon = "moon";
|
||||||
|
else if (title?.includes("⛅")) icon = "cloud-sun";
|
||||||
|
else if (title?.includes("☁️")) icon = "cloud";
|
||||||
|
else if (title?.includes("🌫️")) icon = "smog";
|
||||||
|
else if (title?.includes("🌧️")) icon = "cloud-rain";
|
||||||
|
else if (title?.includes("❄️")) icon = "snowflake";
|
||||||
|
else if (title?.includes("🌦️")) icon = "cloud-sun-rain";
|
||||||
|
else if (title?.includes("🌨️")) icon = "cloud-meatball";
|
||||||
|
else if (title?.includes("⛈️")) icon = "bolt";
|
||||||
|
|
||||||
|
return { temp, city, icon };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWeatherMarkerForPost(post, mapRef, markersRef, expandedElRef, theme = "blue") {
|
||||||
|
try {
|
||||||
|
const map = mapRef?.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const lat = typeof post?.lat === "number" ? post.lat : typeof post?.latitude === "number" ? post.latitude : null;
|
||||||
|
const lon = typeof post?.lon === "number" ? post.lon : typeof post?.lng === "number" ? post.lng : null;
|
||||||
|
if (lat == null || lon == null) return;
|
||||||
|
|
||||||
|
const lngLat = [lon, lat];
|
||||||
|
const { temp, city, icon } = parseWeatherFromTitle(post?.title || "");
|
||||||
|
const emoji = WEATHER_ICONS[icon] || "🌡️";
|
||||||
|
const tempDisplay = temp !== null ? `${temp}°` : "";
|
||||||
|
const cityDisplay = city || post?.city || "";
|
||||||
|
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.className = "weather-marker sw-appear";
|
||||||
|
root.style.zIndex = "2";
|
||||||
|
root.__post = post;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try { root.classList.add("sw-appear-in"); } catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="weather-marker-content">
|
||||||
|
<span class="weather-marker-icon">${emoji}</span>
|
||||||
|
<span class="weather-marker-temp">${tempDisplay}</span>
|
||||||
|
</div>
|
||||||
|
<div class="weather-marker-city">${cityDisplay}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Click to open weather modal
|
||||||
|
root.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(new CustomEvent("weather-open", { detail: post }));
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hover effect
|
||||||
|
root.addEventListener("mouseenter", () => {
|
||||||
|
try {
|
||||||
|
root.style.transform = "scale(1.1)";
|
||||||
|
root.style.zIndex = "100";
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
root.addEventListener("mouseleave", () => {
|
||||||
|
try {
|
||||||
|
root.style.transform = "";
|
||||||
|
root.style.zIndex = "2";
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const marker = new window.maplibregl.Marker({ element: root, anchor: "bottom" })
|
||||||
|
.setLngLat(lngLat)
|
||||||
|
.addTo(map);
|
||||||
|
|
||||||
|
const id = post?.id || post?.ID || `weather-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
if (!Array.isArray(markersRef.current)) markersRef.current = [];
|
||||||
|
|
||||||
|
// Remove existing marker with same id
|
||||||
|
const existing = markersRef.current.findIndex((m) => m?.id === id);
|
||||||
|
if (existing >= 0) {
|
||||||
|
try { markersRef.current[existing]?.marker?.remove?.(); } catch {}
|
||||||
|
markersRef.current.splice(existing, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add with same structure as other markers (type, el, marker, post, id)
|
||||||
|
markersRef.current.push({
|
||||||
|
id,
|
||||||
|
marker,
|
||||||
|
el: root,
|
||||||
|
post: root.__post,
|
||||||
|
type: "weather"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("createWeatherMarkerForPost error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crée un marqueur simple (juste icône circulaire) pour les posts secondaires
|
* Crée un marqueur simple (juste icône circulaire) pour les posts secondaires
|
||||||
* S'ouvre en modal au clic, affiche mini-carte au hover
|
* S'ouvre en modal au clic, affiche mini-carte au hover
|
||||||
|
|
@ -2186,53 +2323,85 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
const root = document.createElement("div");
|
const root = document.createElement("div");
|
||||||
root.classList.add("sw-appear");
|
root.classList.add("sw-appear");
|
||||||
requestAnimationFrame(() => root.classList.add("sw-appear-in"));
|
requestAnimationFrame(() => root.classList.add("sw-appear-in"));
|
||||||
|
const isWeatherPost = post?.content_type === 'weather';
|
||||||
root.className = "post-pin--simple sw-appear sw-appear-in";
|
root.className = "post-pin--simple sw-appear sw-appear-in";
|
||||||
root.style.cursor = "pointer";
|
root.style.cursor = "pointer";
|
||||||
root.style.width = "40px";
|
|
||||||
root.style.height = "52px";
|
|
||||||
root.style.display = "flex";
|
root.style.display = "flex";
|
||||||
root.style.flexDirection = "column";
|
root.style.flexDirection = "column";
|
||||||
root.style.alignItems = "center";
|
root.style.alignItems = "center";
|
||||||
|
root.style.zIndex = isWeatherPost ? "10" : "2";
|
||||||
root.__post = post;
|
root.__post = post;
|
||||||
|
|
||||||
function renderSimple() {
|
function renderSimple() {
|
||||||
const activePost = root.__post || post;
|
const activePost = root.__post || post;
|
||||||
const isCamera = activePost?.content_type === 'camera';
|
const isCamera = activePost?.content_type === 'camera';
|
||||||
const isLive = activePost?.content_type === 'live';
|
const isLive = activePost?.content_type === 'live';
|
||||||
|
const isWeather = activePost?.content_type === 'weather';
|
||||||
const liveColor = 'linear-gradient(135deg, rgba(220,38,38,0.95), rgba(239,68,68,0.95))';
|
const liveColor = 'linear-gradient(135deg, rgba(220,38,38,0.95), rgba(239,68,68,0.95))';
|
||||||
const livePin = 'rgba(220,38,38,0.95)';
|
const livePin = 'rgba(220,38,38,0.95)';
|
||||||
|
const weatherColor = 'linear-gradient(135deg, #0ea5e9 0%, #3b82f6 100%)';
|
||||||
|
const weatherPin = '#3b82f6';
|
||||||
const color = isLive
|
const color = isLive
|
||||||
? liveColor
|
? liveColor
|
||||||
: isCamera
|
: isCamera
|
||||||
? 'linear-gradient(135deg, rgba(56, 189, 248, 0.9), rgba(59, 130, 246, 0.9))'
|
? 'linear-gradient(135deg, rgba(56, 189, 248, 0.9), rgba(59, 130, 246, 0.9))'
|
||||||
|
: isWeather
|
||||||
|
? weatherColor
|
||||||
: getMarkerColorForCategory(activePost?.category);
|
: getMarkerColorForCategory(activePost?.category);
|
||||||
const pinColor = isLive
|
const pinColor = isLive
|
||||||
? livePin
|
? livePin
|
||||||
: isCamera
|
: isCamera
|
||||||
? 'rgba(56, 189, 248, 0.9)'
|
? 'rgba(56, 189, 248, 0.9)'
|
||||||
|
: isWeather
|
||||||
|
? weatherPin
|
||||||
: getMarkerColorForCategory(activePost?.category);
|
: getMarkerColorForCategory(activePost?.category);
|
||||||
const img = normalizeImageUrl(activePost?.image_small || "");
|
const img = normalizeImageUrl(activePost?.image_small || "");
|
||||||
root.__lastTemplateKey = getTemplateKeyForPost(activePost);
|
root.__lastTemplateKey = getTemplateKeyForPost(activePost);
|
||||||
root.__lastImage = img || "";
|
root.__lastImage = img || "";
|
||||||
root.__lastTitle = (activePost?.title || "").toString();
|
root.__lastTitle = (activePost?.title || "").toString();
|
||||||
|
|
||||||
|
// Weather emoji based on weather_icon or title
|
||||||
|
let weatherEmoji = '🌡️';
|
||||||
|
if (isWeather) {
|
||||||
|
const icon = activePost?.weather_icon || '';
|
||||||
|
const title = activePost?.title || '';
|
||||||
|
if (icon === 'sun' || title.includes('☀️')) weatherEmoji = '☀️';
|
||||||
|
else if (icon === 'moon' || title.includes('🌙')) weatherEmoji = '🌙';
|
||||||
|
else if (icon === 'cloud-sun' || title.includes('⛅')) weatherEmoji = '⛅';
|
||||||
|
else if (icon === 'cloud' || title.includes('☁️')) weatherEmoji = '☁️';
|
||||||
|
else if (icon === 'smog' || title.includes('🌫️')) weatherEmoji = '🌫️';
|
||||||
|
else if (icon.includes('rain') || title.includes('🌧️')) weatherEmoji = '🌧️';
|
||||||
|
else if (icon === 'snowflake' || title.includes('❄️')) weatherEmoji = '❄️';
|
||||||
|
else if (title.includes('🌦️')) weatherEmoji = '🌦️';
|
||||||
|
else if (title.includes('🌨️')) weatherEmoji = '🌨️';
|
||||||
|
else if (icon === 'bolt' || title.includes('⛈️')) weatherEmoji = '⛈️';
|
||||||
|
}
|
||||||
|
// Parse temp from title if not available: "☀️ Montreal: 5°C - Clear"
|
||||||
|
let weatherTemp = '';
|
||||||
|
if (isWeather) {
|
||||||
|
if (activePost?.temperature != null) {
|
||||||
|
weatherTemp = `${Math.round(activePost.temperature)}°`;
|
||||||
|
} else {
|
||||||
|
const titleMatch = (activePost?.title || '').match(/:\s*(-?\d+)°/);
|
||||||
|
if (titleMatch) weatherTemp = `${titleMatch[1]}°`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Marqueur pin avec image en haut et pointe en bas
|
// Marqueur pin avec image en haut et pointe en bas
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="sw-simple-marker ${isCamera ? 'sw-camera-marker' : ''} ${isLive ? 'sw-live-marker' : ''}" style="
|
<div class="sw-simple-marker ${isCamera ? 'sw-camera-marker' : ''} ${isLive ? 'sw-live-marker' : ''} ${isWeather ? 'sw-weather-marker' : ''}" style="
|
||||||
width: 32px;
|
${isWeather ? 'width: auto; height: auto; padding: 6px 10px; border-radius: 16px;' : 'width: 32px; height: 32px; border-radius: 50%;'}
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: ${color};
|
background: ${color};
|
||||||
border: 2px solid ${isCamera ? 'rgba(255,255,255,0.85)' : isLive ? 'rgba(255,255,255,0.9)' : 'white'};
|
border: 2px solid ${isCamera ? 'rgba(255,255,255,0.85)' : isLive ? 'rgba(255,255,255,0.9)' : isWeather ? 'rgba(255,255,255,0.4)' : 'white'};
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3)${isCamera ? ', 0 0 10px rgba(56, 189, 248, 0.5)' : isLive ? ', 0 0 12px rgba(239,68,68,0.6)' : ''};
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3)${isCamera ? ', 0 0 10px rgba(56, 189, 248, 0.5)' : isLive ? ', 0 0 12px rgba(239,68,68,0.6)' : isWeather ? ', 0 3px 10px rgba(0,0,0,0.4)' : ''};
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
${isWeather ? 'gap: 4px;' : ''}
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
z-index: 2;
|
|
||||||
">
|
">
|
||||||
${isLive ? `<i class="fa-solid fa-broadcast-tower" style="font-size:14px;color:#fff;"></i>` : isCamera ? `<i class="fa-solid fa-video" style="font-size:14px;color:#fff;"></i>` : img ? `<img src="${img}" alt="" style="width:100%;height:100%;object-fit:cover;" />` : `
|
${isWeather ? `<span style="font-size:16px;line-height:1;">${weatherEmoji}</span><span style="font-size:13px;font-weight:700;color:#fff;">${weatherTemp}</span>` : isLive ? `<i class="fa-solid fa-broadcast-tower" style="font-size:14px;color:#fff;"></i>` : isCamera ? `<i class="fa-solid fa-video" style="font-size:14px;color:#fff;"></i>` : img ? `<img src="${img}" alt="" style="width:100%;height:100%;object-fit:cover;" />` : `
|
||||||
<div style="
|
<div style="
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
|
|
@ -2242,7 +2411,7 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
"></div>
|
"></div>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
<div class="sw-simple-pin-point" style="
|
${!isWeather ? `<div class="sw-simple-pin-point" style="
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
border-left: 8px solid transparent;
|
border-left: 8px solid transparent;
|
||||||
|
|
@ -2251,7 +2420,7 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
||||||
margin-top: -2px;
|
margin-top: -2px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
"></div>
|
"></div>` : `<div style="position:absolute;bottom:-24px;left:50%;transform:translateX(-50%);font-size:13px;font-weight:700;color:#fff;background:rgba(0,0,0,0.85);padding:4px 10px;border-radius:6px;white-space:nowrap;">${escapeHtml(activePost?.city || '')}</div>`}
|
||||||
<div class="sw-simple-hover-card" style="
|
<div class="sw-simple-hover-card" style="
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 100%;
|
bottom: 100%;
|
||||||
|
|
@ -2273,6 +2442,8 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
">
|
">
|
||||||
${isCamera ? `<div style="display:inline-flex;align-items:center;gap:4px;padding:2px 6px;margin-bottom:4px;border-radius:999px;background:rgba(239,68,68,0.85);color:#fff;font-size:9px;font-weight:800;">
|
${isCamera ? `<div style="display:inline-flex;align-items:center;gap:4px;padding:2px 6px;margin-bottom:4px;border-radius:999px;background:rgba(239,68,68,0.85);color:#fff;font-size:9px;font-weight:800;">
|
||||||
<span style="width:5px;height:5px;border-radius:50%;background:#fff;"></span>LIVE
|
<span style="width:5px;height:5px;border-radius:50%;background:#fff;"></span>LIVE
|
||||||
|
</div>` : isWeather ? `<div style="display:inline-flex;align-items:center;gap:4px;padding:2px 6px;margin-bottom:4px;border-radius:999px;background:linear-gradient(135deg,#0ea5e9,#3b82f6);color:#fff;font-size:9px;font-weight:800;">
|
||||||
|
${weatherEmoji} WEATHER
|
||||||
</div>` : ""}
|
</div>` : ""}
|
||||||
<div style="font-weight: 900; font-size: 12px; color: #e8f5ff; margin-bottom: 4px;">
|
<div style="font-weight: 900; font-size: 12px; color: #e8f5ff; margin-bottom: 4px;">
|
||||||
${escapeHtml(activePost?.title || "Untitled")}
|
${escapeHtml(activePost?.title || "Untitled")}
|
||||||
|
|
@ -2281,7 +2452,7 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
||||||
${escapeHtml((activePost?.snippet || "").substring(0, 80))}${(activePost?.snippet || "").length > 80 ? "..." : ""}
|
${escapeHtml((activePost?.snippet || "").substring(0, 80))}${(activePost?.snippet || "").length > 80 ? "..." : ""}
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top: 6px; font-size: 9px; color: rgba(232, 245, 255, 0.6);">
|
<div style="margin-top: 6px; font-size: 9px; color: rgba(232, 245, 255, 0.6);">
|
||||||
${isCamera ? `<i class="fa-solid fa-video" style="margin-right:4px;"></i>` : ""}${normCatLabel(activePost)} • ${isCamera ? (activePost?.region || "Quebec") : formatRelativeTime(activePost?.created_at || activePost?.CreatedAt)}
|
${isCamera ? `<i class="fa-solid fa-video" style="margin-right:4px;"></i>` : isWeather ? `${weatherEmoji} ` : ""}${normCatLabel(activePost)} • ${isCamera ? (activePost?.region || "Quebec") : isWeather ? (activePost?.city || '') : formatRelativeTime(activePost?.created_at || activePost?.CreatedAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,8 @@ export function useMapCore(theme) {
|
||||||
} catch {}
|
} catch {}
|
||||||
}, 650);
|
}, 650);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
// Weather/special pins will be handled via /api/map-pins endpoint (TODO)
|
||||||
});
|
});
|
||||||
|
|
||||||
map.on("moveend", () => {
|
map.on("moveend", () => {
|
||||||
|
|
|
||||||
|
|
@ -756,6 +756,12 @@ export function usePostsEngine({
|
||||||
for (const post of visible) {
|
for (const post of visible) {
|
||||||
const id = getId(post);
|
const id = getId(post);
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
|
// Weather posts always simple (blue bubble), never full card
|
||||||
|
const ct = (post?.content_type || "").toLowerCase();
|
||||||
|
if (ct === "weather") {
|
||||||
|
wanted.set(id, { post, type: 'simple' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Tag avec le type de marqueur souhaité
|
// Tag avec le type de marqueur souhaité
|
||||||
const isFullCard =
|
const isFullCard =
|
||||||
id === forceFullPostId ||
|
id === forceFullPostId ||
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
.weather-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-modal {
|
||||||
|
background: linear-gradient(135deg, #1a1f35 0%, #0d1117 100%);
|
||||||
|
border-radius: 20px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-modal--loading,
|
||||||
|
.weather-modal--error {
|
||||||
|
padding: 60px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-loading-spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-top-color: #60a5fa;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-modal-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-modal-header {
|
||||||
|
padding: 24px 24px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-modal-country {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-current {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-current-icon {
|
||||||
|
font-size: 72px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-current-temp {
|
||||||
|
font-size: 80px;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-current-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-condition {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-feels {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-stat-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-stat-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-tabs {
|
||||||
|
display: flex;
|
||||||
|
padding: 0 24px;
|
||||||
|
margin-top: 16px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-tabs button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-tabs button.active {
|
||||||
|
background: rgba(96, 165, 250, 0.3);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-tabs button:hover:not(.active) {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-hourly {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-hourly::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-hourly::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-hourly::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-hourly-item {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-hourly-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-hourly-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-hourly-temp {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-daily {
|
||||||
|
padding: 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-daily-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-daily-day {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-daily-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-daily-temps {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-daily-high {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-daily-low {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-daily-precip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #60a5fa;
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-modal-footer {
|
||||||
|
padding: 12px 24px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.weather-modal {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-current-temp {
|
||||||
|
font-size: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-current-icon {
|
||||||
|
font-size: 56px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { fetchWeatherByCity, fetchWeatherByPostId, fetchWeatherByCoords } from "../../api/client";
|
||||||
|
import "./WeatherModal.css";
|
||||||
|
|
||||||
|
const weatherIcons = {
|
||||||
|
sun: "\u2600\ufe0f",
|
||||||
|
moon: "\ud83c\udf19",
|
||||||
|
"cloud-sun": "\u26c5",
|
||||||
|
"cloud-moon": "\u2601\ufe0f",
|
||||||
|
cloud: "\u2601\ufe0f",
|
||||||
|
smog: "\ud83c\udf2b\ufe0f",
|
||||||
|
"cloud-rain": "\ud83c\udf27\ufe0f",
|
||||||
|
"cloud-showers-heavy": "\ud83c\udf27\ufe0f",
|
||||||
|
snowflake: "\u2744\ufe0f",
|
||||||
|
"cloud-sun-rain": "\ud83c\udf26\ufe0f",
|
||||||
|
"cloud-meatball": "\ud83c\udf28\ufe0f",
|
||||||
|
bolt: "\u26c8\ufe0f",
|
||||||
|
"temperature-half": "\ud83c\udf21\ufe0f"
|
||||||
|
};
|
||||||
|
|
||||||
|
function getWeatherEmoji(iconName) {
|
||||||
|
return weatherIcons[iconName] || "\ud83c\udf21\ufe0f";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayName(dateStr) {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const today = new Date();
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
if (date.toDateString() === today.toDateString()) return "Today";
|
||||||
|
if (date.toDateString() === tomorrow.toDateString()) return "Tomorrow";
|
||||||
|
|
||||||
|
return date.toLocaleDateString("en-US", { weekday: "short" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHour(timeStr) {
|
||||||
|
const date = new Date(timeStr);
|
||||||
|
return date.toLocaleTimeString("en-US", { hour: "numeric", hour12: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WeatherModal({ postId, city, lat, lon, onClose }) {
|
||||||
|
const [weather, setWeather] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [tab, setTab] = useState("hourly");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
let data = null;
|
||||||
|
|
||||||
|
if (postId) {
|
||||||
|
data = await fetchWeatherByPostId(postId);
|
||||||
|
} else if (city) {
|
||||||
|
data = await fetchWeatherByCity(city);
|
||||||
|
} else if (lat !== undefined && lon !== undefined) {
|
||||||
|
data = await fetchWeatherByCoords(lat, lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
setWeather(data);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, [postId, city, lat, lon]);
|
||||||
|
|
||||||
|
const handleBackdropClick = (e) => {
|
||||||
|
if (e.target.classList.contains("weather-modal-backdrop")) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="weather-modal-backdrop" onClick={handleBackdropClick}>
|
||||||
|
<div className="weather-modal weather-modal--loading">
|
||||||
|
<div className="weather-loading-spinner" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!weather) {
|
||||||
|
return (
|
||||||
|
<div className="weather-modal-backdrop" onClick={handleBackdropClick}>
|
||||||
|
<div className="weather-modal weather-modal--error">
|
||||||
|
<p>Weather data unavailable</p>
|
||||||
|
<button onClick={onClose}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = weather.current || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="weather-modal-backdrop" onClick={handleBackdropClick}>
|
||||||
|
<div className="weather-modal">
|
||||||
|
<button className="weather-modal-close" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<header className="weather-modal-header">
|
||||||
|
<h2>{weather.city}</h2>
|
||||||
|
<span className="weather-modal-country">{weather.country}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="weather-current">
|
||||||
|
<div className="weather-current-icon">
|
||||||
|
{getWeatherEmoji(current.weather_icon)}
|
||||||
|
</div>
|
||||||
|
<div className="weather-current-temp">
|
||||||
|
{Math.round(current.temperature)}°
|
||||||
|
</div>
|
||||||
|
<div className="weather-current-details">
|
||||||
|
<span className="weather-condition">{current.weather_text}</span>
|
||||||
|
<span className="weather-feels">
|
||||||
|
Feels like {Math.round(current.feels_like)}°
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="weather-stats">
|
||||||
|
<div className="weather-stat">
|
||||||
|
<span className="weather-stat-icon">💧</span>
|
||||||
|
<span className="weather-stat-value">{current.humidity}%</span>
|
||||||
|
<span className="weather-stat-label">Humidity</span>
|
||||||
|
</div>
|
||||||
|
<div className="weather-stat">
|
||||||
|
<span className="weather-stat-icon">🌬️</span>
|
||||||
|
<span className="weather-stat-value">{Math.round(current.wind_speed)} km/h</span>
|
||||||
|
<span className="weather-stat-label">Wind</span>
|
||||||
|
</div>
|
||||||
|
<div className="weather-stat">
|
||||||
|
<span className="weather-stat-icon">☁️</span>
|
||||||
|
<span className="weather-stat-value">{current.cloud_cover}%</span>
|
||||||
|
<span className="weather-stat-label">Clouds</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav className="weather-tabs">
|
||||||
|
<button
|
||||||
|
className={tab === "hourly" ? "active" : ""}
|
||||||
|
onClick={() => setTab("hourly")}
|
||||||
|
>
|
||||||
|
24 Hours
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={tab === "daily" ? "active" : ""}
|
||||||
|
onClick={() => setTab("daily")}
|
||||||
|
>
|
||||||
|
7 Days
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{tab === "hourly" && weather.hourly && (
|
||||||
|
<section className="weather-hourly">
|
||||||
|
{weather.hourly.slice(0, 24).map((h, i) => (
|
||||||
|
<div key={i} className="weather-hourly-item">
|
||||||
|
<span className="weather-hourly-time">{formatHour(h.time)}</span>
|
||||||
|
<span className="weather-hourly-icon">
|
||||||
|
{h.weather_code === 0 ? "\u2600\ufe0f" : h.weather_code <= 3 ? "\u26c5" : h.weather_code <= 48 ? "\ud83c\udf2b\ufe0f" : h.weather_code <= 67 ? "\ud83c\udf27\ufe0f" : "\u2744\ufe0f"}
|
||||||
|
</span>
|
||||||
|
<span className="weather-hourly-temp">{Math.round(h.temperature)}°</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "daily" && weather.daily && (
|
||||||
|
<section className="weather-daily">
|
||||||
|
{weather.daily.map((d, i) => (
|
||||||
|
<div key={i} className="weather-daily-item">
|
||||||
|
<span className="weather-daily-day">{getDayName(d.date)}</span>
|
||||||
|
<span className="weather-daily-icon">
|
||||||
|
{d.weather_code === 0 ? "\u2600\ufe0f" : d.weather_code <= 3 ? "\u26c5" : d.weather_code <= 48 ? "\ud83c\udf2b\ufe0f" : d.weather_code <= 67 ? "\ud83c\udf27\ufe0f" : "\u2744\ufe0f"}
|
||||||
|
</span>
|
||||||
|
<span className="weather-daily-temps">
|
||||||
|
<span className="weather-daily-high">{Math.round(d.temp_max)}°</span>
|
||||||
|
<span className="weather-daily-low">{Math.round(d.temp_min)}°</span>
|
||||||
|
</span>
|
||||||
|
<span className="weather-daily-precip">
|
||||||
|
{d.precipitation_sum > 0 && `\ud83d\udca7 ${d.precipitation_sum.toFixed(1)}mm`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="weather-modal-footer">
|
||||||
|
<span>Data: Open-Meteo</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { default as WeatherModal } from "./WeatherModal";
|
||||||
|
|
@ -1300,3 +1300,71 @@ body[data-theme="light"] .sw-modal-x{
|
||||||
grid-template-columns: 28px 1fr;
|
grid-template-columns: 28px 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Weather Markers */
|
||||||
|
.weather-marker {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-marker-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
background: linear-gradient(135deg, #0ea5e9 0%, #3b82f6 100%);
|
||||||
|
border-radius: 28px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.5);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-marker-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-marker-temp {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-marker-city {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
bottom: -24px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-marker:hover .weather-marker-content {
|
||||||
|
background: linear-gradient(135deg, #38bdf8 0%, #60a5fa 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-marker::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: -6px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 6px solid transparent;
|
||||||
|
border-right: 6px solid transparent;
|
||||||
|
border-top: 8px solid #3b82f6;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue