import { kmPerDeg } from "./distaz"; import { Quake, createQuakeClickEvent, QuakeClickEventMap } from "./quakeml"; import * as import_leaflet_css from "./leaflet_css"; import { Network, Station, createStationClickEvent, StationClickEventMap } from "./stationxml"; import { SeisPlotElement } from "./spelement"; import { SeismogramDisplayData, uniqueQuakes, uniqueStations, } from "./seismogram"; import { SeismographConfig } from "./seismographconfig"; import { LatLonBox, LatLonRadius } from "./fdsncommon"; import { fixProtocolInUrl } from "./util"; import * as L from "leaflet"; import { LatLngTuple } from "leaflet"; export const HIGHLIGHT = "highlight"; export const MAP_ELEMENT = "sp-station-quake-map"; export const TRIANGLE = "triangle"; export const DOWNTRIANGLE = "downtriangle"; export const SQUARE = "square"; export const CROSS = "cross"; export const StationMarkerClassName = "stationMapMarker"; export const InactiveStationMarkerClassName = "inactiveStationMapMarker"; export const QuakeMarkerClassName = "quakeMapMarker"; export const STATION_ICON_SIZE = 18; export const stationIcon = L.divIcon({ className: StationMarkerClassName, iconSize: [STATION_ICON_SIZE,STATION_ICON_SIZE], }); export const inactiveStationIcon = L.divIcon({ className: InactiveStationMarkerClassName, iconSize: [STATION_ICON_SIZE,STATION_ICON_SIZE], }); export const defaultMapElement_css = ` :host { display: block } div.wrapper { height: 100%; min-height: 100px; } .leaflet-container { height: 100%; width: 100%; } `; export const defaultMarker_css = ` .${StationMarkerClassName}.${InactiveStationMarkerClassName} { fill: darkgrey; stroke: darkgrey; z-index: 1; } .${StationMarkerClassName} { z-index: 10; fill: royalblue; stroke: royalblue; } .${StationMarkerClassName} svg { background: none; } .${QuakeMarkerClassName} { stroke: red; fill: #f03; fill-opacity: 0.15; } .${StationMarkerClassName}.${HIGHLIGHT} { stroke: white; } .${QuakeMarkerClassName}.${HIGHLIGHT} { stroke: white; } `; /** * Default marker css * @deprecated */ export const stationMarker_css = defaultMarker_css; /** * Create CSS class based on station codes. * @param station the station * @return selector string like sta_CO_JSC */ export function cssClassForStationCodes(station: Station): string { return `sta_${station.codes(STATION_CODE_SEP)}`; } /** * Create CSS class based on network codes. * @param network the network * @return selector string like net_CO */ export function cssClassForNetworkCode(network: Network): string { return `net_${network.networkCode}`; } export function createStationSVG(iconSize=STATION_ICON_SIZE, symbol: string=TRIANGLE): string { const strokeWidth=2; const xCent = iconSize/2; const yCent = iconSize/2; const shift = (iconSize-strokeWidth)/2; let out = ``; // debug crosshairs //out += ` // `; if (symbol === DOWNTRIANGLE) { out+= ` `; } else if (symbol === SQUARE) { out+= ` `; } else if (symbol === CROSS) { out += ` `; } else { //if (symbol === TRIANGLE) { out += ` `; } out += ""; return out; } /** * Create a station marker as a leaflet divIcon. Also binds the station codes * as a tooltip. * @param station the station, with lat, lon * @param classList additional css class names to add * @param isactive=true adds inactiveStationMapMarker to class list if not active * @param centerLon=0 center map longitude, station lon are adjusted by +-360 to be closest to this * @param iconSize=STATION_ICON_SIZE optional icon size * @param iconSymbol=TRIANGLE optional icon symbol, one of triangle, downtriangle, square or cross * @return leaflet marker for the station */ export function createStationMarker( station: Station, classList?: Array, isactive = true, centerLon = 0, iconSize = STATION_ICON_SIZE, iconSymbol = TRIANGLE, ) { const allClassList = (classList!=null) ? classList.slice() : []; allClassList.push( isactive ? StationMarkerClassName : InactiveStationMarkerClassName, ); allClassList.push(cssClassForStationCodes(station)); allClassList.push(cssClassForNetworkCode(station.network)); const icon = L.divIcon({ html: createStationSVG(iconSize, iconSymbol), className: allClassList.join(" "), iconSize: [iconSize,iconSize], iconAnchor: [iconSize/2,iconSize/2] }); const sLon = station.longitude - centerLon <= 180 ? station.longitude : station.longitude - 360; const m = L.marker([station.latitude, sLon], { icon: icon, }); m.bindTooltip(station.codes()); return m; } export function getRadiusForMag(magnitude: number, magScaleFactor: number): number { // in case no mag let radius = magnitude ? magnitude * magScaleFactor : 1; if (radius < 1) { radius = 1; } return radius; } /** * Create a circle marker for Quake. Radius is linearly scaled by magnitude, * with min radius of 1 for very small magnitudes. Longitudes are adjusted * by +-360 to draw centered on the given center longitude, eg event at * lon=350 may plot at -10 if centerlon < 180. * @param quake earthquake * @param magScaleFactor scale factor * @param classList CSS classes to attach * @param centerLon center longitude of the map * @returns leaflet circleMarker */ export function createQuakeMarker( quake: Quake, magScaleFactor = 5, classList?: Array, centerLon = 0, magToRadius:(magnitude: number, magScaleFactor: number)=> number = getRadiusForMag ) { const allClassList = classList ? classList.slice() : []; allClassList.push(QuakeMarkerClassName); allClassList.push(cssClassForQuake(quake)); const qLon = quake.longitude - centerLon <= 180 ? quake.longitude : quake.longitude - 360; // in case no mag const magnitude = quake.magnitude ? quake.magnitude.mag : 1; const radius = magToRadius(magnitude, magScaleFactor); const circle = L.circleMarker([quake.latitude, qLon], { color: "currentColor", radius: radius, className: allClassList.join(" "), }); const magStr = quake.magnitude ? quake.magnitude.toString() : "unkn"; circle.bindTooltip(`${quake.time.toISO()} ${magStr}`); return circle; } export const leaflet_css = import_leaflet_css.leaflet_css; export const TILE_TEMPLATE = "tileUrl"; export const DEFAULT_TILE_TEMPLATE = "https://services.arcgisonline.com/arcgis/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}"; export const TILE_ATTRIBUTION = "tileAttribution"; export const MAX_ZOOM = "maxZoom"; export const DEFAULT_MAX_ZOOM = 17; export const CENTER_LAT = "centerLat"; export const DEFAULT_CENTER_LAT = 35; export const CENTER_LON = "centerLon"; export const DEFAULT_CENTER_LON = -81; export const ZOOM_LEVEL = "zoomLevel"; export const DEFAULT_ZOOM_LEVEL = 1; export const MAG_SCALE = "magScale"; export const DEFAULT_MAG_SCALE = 5.0; export const FIT_BOUNDS = "fitBounds"; export const QUAKE_MARKER_STYLE_EL = "quakeMarkerStyle"; export const STATION_MARKER_STYLE_EL = "staMarkerStyle"; export const STATION_CODE_SEP = "_"; export const LEAFLET_CSS_ID = "leafletcss"; export const MAP_CSS_ID = "stationquakemapcss"; export const MARKER_CSS_ID = "defaultmarkercss"; export interface QuakeStationMapEventMap extends StationClickEventMap, QuakeClickEventMap { } export interface QuakeStationMap extends SeisPlotElement { // overload for custom events addEventListener(type: E, listener: (ev: QuakeStationMapEventMap[E]) => any): void; } export class QuakeStationMap extends SeisPlotElement { quakeList: Array = []; stationList: Array = []; geoRegionList: Array = []; map: L.Map | null; classToColor: Map; mapItems: Array = []; stationClassMap: Map>; quakeClassMap: Map>; quakeLayer = L.layerGroup(); quakeLayerName = "Quakes"; stationLayer = L.layerGroup(); stationLayerName = "Stations"; stationIconSize=STATION_ICON_SIZE; stationIconSymbol=TRIANGLE; constructor( seisData?: Array, seisConfig?: SeismographConfig, ) { super(seisData, seisConfig); this.map = null; this.classToColor = new Map(); this.stationClassMap = new Map>(); this.quakeClassMap = new Map>(); this.addStyle(leaflet_css, LEAFLET_CSS_ID); this.addStyle(defaultMapElement_css, MAP_CSS_ID); this.addStyle(stationMarker_css, MARKER_CSS_ID); const wrapper = document.createElement("div"); wrapper.setAttribute("class", "wrapper"); this.getShadowRoot().appendChild(wrapper); } addQuake(quake: Quake | Array, classname?: string) { const re = /\s+/; let classList: Array = []; if (classname && classname.length > 0) { classList = classname.split(re); } if (Array.isArray(quake)) { quake.forEach((q) => this.quakeList.push(q)); classList.forEach((cn) => { quake.forEach((q) => this.quakeAddClass(q, cn)); }); } else { this.quakeList.push(quake); classList.forEach((cn) => { this.quakeAddClass(quake, cn); }); } } allQuakes(): Array { const quakes = this.quakeList.concat(uniqueQuakes(this.seisData)); return quakes; } /** * Adds a css class for the quake icon for additional styling, * either via addStyle() for general or via colorClass() for just * simply coloring. * * @param quake the quake * @param classname css class name */ quakeAddClass(quake: Quake, classname: string) { const quakeIdStr = cssClassForQuake(quake); const classList = this.quakeClassMap.get(quakeIdStr); if (classList) { classList.push(classname); } else { this.quakeClassMap.set(cssClassForQuake(quake), [classname]); } const circleList = this.getShadowRoot().querySelectorAll( `path.${quakeIdStr}`, ); circleList.forEach((c) => { c.classList.add(classname); }); } /** * Removes a css class from the earthquake circle. * * @param quake quake to remove * @param classname class to remove */ quakeRemoveClass(quake: Quake, classname: string) { const quakeIdStr = cssClassForQuake(quake); let classList = this.quakeClassMap.get(quakeIdStr); if (classList) { classList = classList.filter((v) => v !== classname); this.quakeClassMap.set(cssClassForQuake(quake), classList); } const circleList = this.getShadowRoot().querySelectorAll( `path.${quakeIdStr}`, ); circleList.forEach((c) => { c.classList.remove(classname); }); } /** * Removes a css class from all earthquake circles. * * @param classname class to remove */ quakeRemoveAllClass(classname: string) { this.allQuakes().forEach((q) => this.quakeRemoveClass(q, classname)); } quakeUnhighlight() { this.allQuakes().forEach((q:Quake) => { this.quakeRemoveClass(q, HIGHLIGHT); }); } quakeHighlight(quakeList: Array|Quake) { if (! Array.isArray(quakeList)) { quakeList = [ quakeList ]; } this.quakeUnhighlight(); quakeList.forEach((q:Quake) => { this.quakeAddClass(q, HIGHLIGHT); }); } addStation(station: Station | Array, classname?: string) { const re = /\s+/; let classList: Array = []; if (classname && classname.length > 0) { classList = classname.split(re); } if (Array.isArray(station)) { station.forEach((s) => this.stationList.push(s)); classList.forEach((cn) => { station.forEach((s) => this.stationAddClass(s, cn)); }); } else { this.stationList.push(station); classList.forEach((cn) => this.stationAddClass(station, cn)); } } /** * Get all stations on the map. Some from seisData and some added directly * to stationList. * @return list of Stations */ allStations(): Array { const stations = this.stationList.concat(uniqueStations(this.seisData)); return stations; } stationIcon(iconSize: number=STATION_ICON_SIZE, iconSymbol: string=TRIANGLE) { this.stationIconSize = iconSize; this.stationIconSymbol = iconSymbol; if (iconSize < 0) {throw new Error(`icon size must be postive number: ${iconSize}`);} } /** * Adds a css class for the station icon for additional styling, * either via addStyle() for general or via colorClass() for just * simply coloring. * * @param station the station * @param classname css class name */ stationAddClass(station: Station, classname: string) { const classList = this.stationClassMap.get(station.codes(STATION_CODE_SEP)); if (classList) { classList.push(classname); } else { this.stationClassMap.set(station.codes(STATION_CODE_SEP), [classname]); } const markerList = this.getShadowRoot().querySelectorAll( `div.${cssClassForStationCodes(station)}`, ); markerList.forEach((c) => { c.classList.add(classname); }); } /** * Removes a css class from the station triangle * * @param station the station * @param classname css class name */ stationRemoveClass(station: Station, classname: string) { let classList = this.stationClassMap.get(station.codes(STATION_CODE_SEP)); if (classList) { classList = classList.filter((v) => v !== classname); this.stationClassMap.set(station.codes(STATION_CODE_SEP), classList); } const markerList = this.getShadowRoot().querySelectorAll( `div.${cssClassForStationCodes(station)}`, ); markerList.forEach((c) => { c.classList.remove(classname); }); } stationUnhighlight() { this.allStations().forEach((sta:Station) => { this.stationRemoveClass(sta, HIGHLIGHT); }); } stationHighlight(stationList: Array|Station) { if (!Array.isArray(stationList)) { stationList = [ stationList ]; } this.stationUnhighlight(); stationList.forEach((sta:Station) => { this.stationAddClass(sta, HIGHLIGHT); }); } /** * Set a color in css for the classname. This is a simple alternative * to full styling via addStyle(). * * @param classname css class name * @param color color, like red */ colorClass(classname: string, color: string) { this.classToColor.set(classname, color); this.updateQuakeMarkerStyle(); this.updateStationMarkerStyle(); } removeColorClass(classname: string) { this.classToColor.delete(classname); this.updateQuakeMarkerStyle(); this.updateStationMarkerStyle(); } get fitBounds(): boolean { const fbAttr = this.hasAttribute(FIT_BOUNDS) ? this.getAttribute(FIT_BOUNDS) : "true"; let fb = true; if (!fbAttr) { fb = true; } else { fb = fbAttr.toLowerCase() === "true"; } return fb; } set fitBounds(val: boolean) { this.setAttribute(FIT_BOUNDS, `${val}`); } get centerLat(): number { const ks = this.hasAttribute(CENTER_LAT) ? this.getAttribute(CENTER_LAT) : null; // typescript null let k; if (!ks) { k = DEFAULT_CENTER_LAT; } else { k = parseFloat(ks); } return k; } set centerLat(val: number) { this.setAttribute(CENTER_LAT, `${val}`); } get centerLon(): number { const ks = this.hasAttribute(CENTER_LON) ? this.getAttribute(CENTER_LON) : null; let k; // typescript null if (!ks) { k = DEFAULT_CENTER_LON; } else { k = parseFloat(ks); } return k; } set centerLon(val: number) { this.setAttribute(CENTER_LON, `${val}`); } get zoomLevel(): number { const ks = this.hasAttribute(ZOOM_LEVEL) ? this.getAttribute(ZOOM_LEVEL) : null; // typescript null let k; if (!ks) { k = DEFAULT_ZOOM_LEVEL; } else { k = parseInt(ks); } return k; } set zoomLevel(val: number) { this.setAttribute(ZOOM_LEVEL, `${val}`); } get magScale(): number { const ks = this.hasAttribute(MAG_SCALE) ? this.getAttribute(MAG_SCALE) : null; let k; // typescript null if (!ks) { k = DEFAULT_MAG_SCALE; } else { k = parseFloat(ks); } return k; } set magScale(val: number) { this.setAttribute(MAG_SCALE, `${val}`); } draw() { if (!this.isConnected) { return; } this.updateQuakeMarkerStyle(); this.updateStationMarkerStyle(); const wrapper = this.getShadowRoot().querySelector("div") as HTMLDivElement; while (wrapper.firstChild) { // @ts-expect-error if there is a firstChild, there is also lastChild wrapper.removeChild(wrapper.lastChild); } const divElement = wrapper.appendChild(document.createElement("div")); const mymap = L.map(divElement, {layers:[ this.quakeLayer, this.stationLayer]}).setView( [this.centerLat, this.centerLon], this.zoomLevel, ); this.map = mymap; if (this.seismographConfig.wheelZoom) { mymap.scrollWheelZoom.enable(); } else { mymap.scrollWheelZoom.disable(); } let tileUrl = DEFAULT_TILE_TEMPLATE; let maxZoom = DEFAULT_MAX_ZOOM; const tileUrlAttr = this.getAttribute(TILE_TEMPLATE); if (tileUrlAttr) { tileUrl = tileUrlAttr; } tileUrl = fixProtocolInUrl(tileUrl); const maxZoomAttr = this.getAttribute(MAX_ZOOM); if (maxZoomAttr) { maxZoom = Number.parseInt(maxZoomAttr); } const tileOptions: L.TileLayerOptions = { maxZoom: maxZoom, }; const tileAttributionAttr = this.getAttribute(TILE_ATTRIBUTION); if (tileAttributionAttr) { tileOptions.attribution = tileAttributionAttr; } L.tileLayer(tileUrl, tileOptions).addTo(mymap); const regionBounds = this.drawGeoRegions(mymap); regionBounds.forEach((b) => this.mapItems.push(b)); this.drawStationLayer(); //updates this.mapItems // Draw quakeLayer last so it is the layer on the very top and will respond to click events. this.drawQuakeLayer(); //updates this.mapItems if (this.fitBounds && this.mapItems.length > 1) { mymap.fitBounds(this.mapItems); } } drawQuakeLayer(){ this.quakeLayer.clearLayers(); const quakes = this.allQuakes(); quakes.forEach((q) => { const circle = createQuakeMarker( q, this.magScale, this.quakeClassMap.get(cssClassForQuake(q)), this.centerLon, ); //circle.addTo(mymap); circle.addTo(this.quakeLayer); this.mapItems.push([q.latitude, q.longitude]); circle.addEventListener("click", (evt) => { const ce = createQuakeClickEvent(q, evt.originalEvent); this.dispatchEvent(ce); }); }); if (this.map){ this.quakeLayer.addTo(this.map); } } drawStationLayer(){ this.stationLayer.clearLayers(); const stations = this.allStations(); stations.forEach((s) => { const m = createStationMarker( s, this.stationClassMap.get(s.codes(STATION_CODE_SEP)), true, this.centerLon, this.stationIconSize, this.stationIconSymbol ); m.addTo(this.stationLayer); // for debug, marker at right place on map // const mm = L.marker([s.latitude, s.longitude]); // mm.addTo(this.stationLayer); this.mapItems.push([s.latitude, s.longitude]); m.addEventListener("click", (evt) => { const ce = createStationClickEvent(s, evt.originalEvent); this.dispatchEvent(ce); }); }); if (this.map){ this.stationLayer.addTo(this.map); } } updateQuakeMarkerStyle() { const quakeMarkerStyle = this.createQuakeMarkerColorStyle(); const quakeMarkerStyleEl = this.getShadowRoot().querySelector( `style#${QUAKE_MARKER_STYLE_EL}`, ); if (quakeMarkerStyleEl) { quakeMarkerStyleEl.textContent = quakeMarkerStyle; } else { this.addStyle(quakeMarkerStyle, QUAKE_MARKER_STYLE_EL); } } updateStationMarkerStyle() { const staMarkerStyle = this.createStationMarkerColorStyle(); const staMarkerStyleEl = this.getShadowRoot().querySelector( `style#${STATION_MARKER_STYLE_EL}`, ); if (staMarkerStyleEl) { staMarkerStyleEl.textContent = staMarkerStyle; } else { this.addStyle(staMarkerStyle, STATION_MARKER_STYLE_EL); } } drawGeoRegions(map: L.Map): Array<[number, number]> { const outLatLon: Array<[number, number]> = []; this.geoRegionList.forEach((gr) => { if (gr instanceof LatLonBox) { const llbox = gr; const bounds = llbox.asLeafletBounds(); const rect = L.rectangle(bounds, { color: "red", weight: 1 }); rect.addTo(map); outLatLon.push(bounds[0]); outLatLon.push(bounds[1]); } else if (gr instanceof LatLonRadius) { const llrad = gr; outLatLon.push([llrad.latitude, llrad.longitude]); if (llrad.minRadius > 0) { L.circle([llrad.latitude, llrad.longitude], { radius: llrad.minRadius * 1000 * kmPerDeg, }).addTo(map); outLatLon.push([llrad.latitude + llrad.minRadius, llrad.longitude]); outLatLon.push([llrad.latitude - llrad.minRadius, llrad.longitude]); outLatLon.push([llrad.latitude, llrad.longitude + llrad.minRadius]); outLatLon.push([llrad.latitude, llrad.longitude - llrad.minRadius]); } if (llrad.maxRadius < 180) { L.circle([llrad.latitude, llrad.longitude], { radius: llrad.maxRadius * 1000 * kmPerDeg, }).addTo(map); outLatLon.push([llrad.latitude + llrad.maxRadius, llrad.longitude]); outLatLon.push([llrad.latitude - llrad.maxRadius, llrad.longitude]); outLatLon.push([llrad.latitude, llrad.longitude + llrad.maxRadius]); outLatLon.push([llrad.latitude, llrad.longitude - llrad.maxRadius]); } } else if (gr === null) { // null means whole world } else { // unknown region type? throw new Error(`unknown region type: ${String(gr)}`); } }); return outLatLon; } createStationMarkerColorStyle() { let style = ""; this.classToColor.forEach((color, classname) => { style = `${style} div.leaflet-marker-icon.${StationMarkerClassName}.${classname} { fill: ${color}; stroke: ${color}; } `; }); return style; } createQuakeMarkerColorStyle() { let style = ""; this.classToColor.forEach((color, classname) => { style = `${style} path.${classname} { stroke: ${color}; fill: ${color}; } `; }); return style; } attributeChangedCallback(_name: string, _oldValue: string, _newValue: string) { this.redraw(); } static get observedAttributes(): Array { return [ TILE_TEMPLATE, TILE_ATTRIBUTION, MAX_ZOOM, CENTER_LAT, CENTER_LON, ZOOM_LEVEL, MAG_SCALE, FIT_BOUNDS, ]; } } customElements.define(MAP_ELEMENT, QuakeStationMap); export function cssClassForQuake(q: Quake): string { const badCSSChars = /[^A-Za-z0-9_-]/g; let out; if (q.eventId && q.eventId.length > 0) { out = q.eventId; } else { out = `${q.origin.time.toISO()}_${q.magnitude.toString()}`; } return "qid_" + out.replaceAll(badCSSChars, "_"); }