ol-owm
Version:
Weather layer for OpenLayers and Leaflet using OpenWeatherMap
331 lines (329 loc) • 12.7 kB
JavaScript
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import GeoJSON from "ol/format/GeoJSON";
import Overlay from "ol/Overlay";
import TileLayer from "ol/layer/Tile";
import { Style, Icon, Text, Fill, Stroke } from "ol/style";
import { toLonLat } from "ol/proj";
import { DoubleClickZoom } from "ol/interaction";
import { getSvgImage } from "./hoocks/get.svg.image";
import { layers } from "./layers";
import { XYZ } from "ol/source";
import { makeLegend, removeLegend } from "./hoocks/make.lengnd";
import { WindAnimation } from "./layers/wind";
import { OpenLayersAdapter } from "./adapters/ol";
function lonLatToTile(lon, lat, zoom) {
const x = Math.floor(((lon + 180) / 360) * Math.pow(2, zoom));
const y = Math.floor(((1 -
Math.log(Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180)) /
Math.PI) /
2) *
Math.pow(2, zoom));
return { x, y };
}
function formatUnixTime(timestamp, timezoneOffset) {
const localTimestamp = (timestamp + timezoneOffset) * 1000; // в миллисекунды
const date = new Date(localTimestamp);
const hours = date.getUTCHours().toString().padStart(2, "0");
const minutes = date.getUTCMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
}
const defaultProperties = {
lang: "en",
legend: true,
legendElement: "#map",
windDataURL: null,
};
export class OpenLayersWeather {
constructor(map, owmKey, properties = defaultProperties) {
this.windFetchController = null;
this.tileLayer = null;
this.activeCities = false;
this.activeKey = null;
this.activeWind = false;
this.onMapClick = () => {
if (this.popupOverlay) {
this.popupOverlay.setPosition(undefined);
}
};
this.onMapDoubleClick = async (evt) => {
const coord = toLonLat(evt.coordinate);
const [lon, lat] = coord;
const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&units=metric&lang=${this.properties.lang}&appid=${this.owmKey}`;
try {
const res = await fetch(url);
const data = await res.json();
const { name, weather, main, wind, sys, timezone, clouds, visibility } = data;
const weatherData = weather[0];
const description = weatherData.description;
const mainCondition = weatherData.main;
const sunrise = formatUnixTime(sys.sunrise, timezone);
const sunset = formatUnixTime(sys.sunset, timezone);
this.popupElement.innerHTML = `
<div style="font-family: 'Segoe UI', sans-serif; color: #1a1a1a; min-width: 240px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
<img src="https://openweathermap.org/img/wn/${weather === null || weather === void 0 ? void 0 : weather[0].icon}@2x.png" alt="${mainCondition}" width="48" height="48" style=" object-position: center;
object-fit: cover;
width: 48px;
height: 48px;
background: #404040;
border-radius: 10px;
" />
<div>
<div style="font-size: 16px; font-weight: 600;">${name || "Unknown"}, ${sys.country}</div>
<div style="font-size: 13px; color: #666;">${description}</div>
</div>
</div>
<div style="font-size: 14px; line-height: 1.7;">
🌡️ <b>Temperature:</b> ${main.temp.toFixed(1)}°C<br />
🤒 <b>Feels like:</b> ${main.feels_like.toFixed(1)}°C<br />
📈 <b>Max/Min:</b> ${main.temp_max.toFixed(1)}°C / ${main.temp_min.toFixed(1)}°C<br />
💧 <b>Humidity:</b> ${main.humidity}%<br />
🧭 <b>Pressure:</b> ${main.pressure} hPa<br />
☁️ <b>Cloudiness:</b> ${clouds.all}%<br />
👁 <b>Visibility:</b> ${(visibility / 1000).toFixed(1)} km<br />
🌬️ <b>Wind:</b> ${wind.speed.toFixed(1)} m/s ${wind.gust ? `(gusts up to ${wind.gust.toFixed(1)} m/s)` : ""}<br />
↗ <b>Direction:</b> ${wind.deg}°<br />
🌅 <b>Sunrise:</b> ${sunrise}<br />
🌇 <b>Sunset:</b> ${sunset}
</div>
</div>
`;
this.popupOverlay.setPosition(evt.coordinate);
}
catch (e) {
console.warn("Ошибка запроса погоды:", e);
}
};
this.map = map;
this.owmKey = owmKey;
this.properties = properties;
this.windFetchController = null;
this.wind = new WindAnimation(new OpenLayersAdapter(map), properties.windProperties);
this.onMoveEnd = () => {
this.update();
};
}
status() {
return !!this.layer;
}
layers() {
return layers.map((x) => {
return {
name: x.name,
key: x.key,
};
});
}
setLayer(key) {
if (key === this.activeKey)
return;
if (this.tileLayer) {
removeLegend("-ol");
this.map.removeLayer(this.tileLayer);
this.tileLayer = null;
this.activeKey = null;
}
if (!key)
return;
const layerData = layers.find((l) => l.key === key);
if (!layerData)
return;
const source = new XYZ({
url: layerData.url + this.owmKey,
});
const tileLayer = new TileLayer({
source,
zIndex: 50,
});
this.map.addLayer(tileLayer);
this.tileLayer = tileLayer;
this.activeKey = key;
// Добавляем легенду заново
if (this.properties.legend && this.properties.legendElement) {
makeLegend("-ol", this.properties.legendElement, layerData);
}
}
toggleWind() {
if (this.properties.windDataURL) {
if (!this.activeWind) {
this.activeWind = true;
// отмена предыдущего запроса
if (this.windFetchController) {
this.windFetchController.abort();
}
this.windFetchController = new AbortController();
fetch(this.properties.windDataURL, {
signal: this.windFetchController.signal,
})
.then((r) => r.json())
.then((data) => {
if (this.activeWind) {
this.wind.start(data);
}
})
.catch((e) => {
if (e.name !== "AbortError")
console.warn("Ошибка ветра:", e);
});
}
else {
this.activeWind = false;
this.wind.stop();
// отмена fetch, если ещё идёт
if (this.windFetchController) {
this.windFetchController.abort();
this.windFetchController = null;
}
}
}
}
async show() {
this.activeCities = true;
this.doubleClickZoom = this.map
.getInteractions()
.getArray()
.find((i) => i instanceof DoubleClickZoom);
if (this.doubleClickZoom) {
this.map.removeInteraction(this.doubleClickZoom);
}
this.popupElement = document.createElement("div");
this.popupElement.style.cssText = `
background: white;
padding: 10px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
min-width: 200px;
font-family: sans-serif;
`;
this.popupOverlay = new Overlay({
element: this.popupElement,
positioning: "bottom-center",
stopEvent: false,
offset: [0, -15],
});
this.map.addOverlay(this.popupOverlay);
this.map.on("moveend", this.onMoveEnd);
this.map.on("dblclick", this.onMapDoubleClick);
this.map.on("click", this.onMapClick);
await this.update();
}
hide() {
this.activeCities = false;
this.map.un("dblclick", this.onMapDoubleClick);
this.map.un("click", this.onMapClick);
if (this.popupOverlay) {
this.map.removeOverlay(this.popupOverlay);
}
if (this.doubleClickZoom) {
this.map.addInteraction(this.doubleClickZoom);
}
if (this.layer) {
this.map.removeLayer(this.layer);
this.layer = undefined;
}
}
async update() {
var _a;
const view = this.map.getView();
const zoom = Math.floor((_a = view.getZoom()) !== null && _a !== void 0 ? _a : 6);
const extent = view.calculateExtent(this.map.getSize());
const topLeft = toLonLat([extent[0], extent[3]]);
const bottomRight = toLonLat([extent[2], extent[1]]);
const minTile = lonLatToTile(topLeft[0], topLeft[1], zoom);
const maxTile = lonLatToTile(bottomRight[0], bottomRight[1], zoom);
const features = [];
const requests = [];
for (let x = minTile.x; x <= maxTile.x; x++) {
for (let y = minTile.y; y <= maxTile.y; y++) {
const tileKey = `${zoom}/${x}/${y}`;
const url = `https://b.maps.owm.io/weather/cities/${tileKey}.geojson?appid=${this.owmKey}&lang=${this.properties.lang}`;
requests.push(fetch(url)
.then((r) => r.json())
.then((geojson) => {
const parsed = new GeoJSON().readFeatures(geojson, {
featureProjection: view.getProjection(),
});
features.push(...parsed);
})
.catch((err) => {
console.warn(`Ошибка загрузки тайла ${tileKey}:`, err);
}));
}
}
await Promise.all(requests);
if (!this.activeCities)
return;
const source = new VectorSource({
features,
});
const styleFunction = (feature) => {
const { city, wind_speed, temp } = feature.getProperties();
const rawSvg = getSvgImage(feature.getProperties());
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(rawSvg)}`;
return [
new Style({
image: new Icon({
src: dataUrl,
scale: 0.2,
}),
text: new Text({
text: city,
font: "12px sans-serif",
fill: new Fill({
color: "white",
}),
stroke: new Stroke({
color: "black",
width: 2,
}),
offsetY: 20,
}),
}),
new Style({
text: new Text({
text: parseInt(temp) + "°C",
font: "12px sans-serif",
fill: new Fill({
color: "white",
}),
stroke: new Stroke({
color: "black",
width: 2,
}),
offsetY: -16,
}),
zIndex: 9999999999,
}),
new Style({
text: new Text({
text: wind_speed.toFixed(1) + "м/с",
font: "10px sans-serif",
fill: new Fill({
color: "white",
}),
stroke: new Stroke({
color: "black",
width: 2,
}),
// offsetX: ,
offsetY: 32,
}),
zIndex: 9999999999,
}),
];
};
if (!this.layer) {
this.layer = new VectorLayer({
source,
style: styleFunction,
zIndex: 100,
});
this.map.addLayer(this.layer);
this.map.on("dblclick", this.onMapDoubleClick);
}
else {
this.layer.setSource(source);
}
}
}