/* * Philip Crotwell * University of South Carolina, 2019 * https://www.seis.sc.edu */ //import * as d3 from "d3"; import { select as d3select } from "d3-selection"; import "d3-transition"; import { scaleLinear as d3scaleLinear } from "d3-scale"; import { line as d3line, curveLinear as d3curveLinear } from "d3-shape"; import { axisLeft as d3axisLeft, axisBottom as d3axisBottom } from "d3-axis"; import { Interval } from "luxon"; import { SeisPlotElement } from "./spelement"; import { MinMaxable } from "./scale"; import { SeismographConfig, numberFormatWrapper } from "./seismographconfig"; import { Seismogram, SeismogramDisplayData, findMinMaxOfSDD, } from "./seismogram"; import { SeismogramSegment } from "./seismogramsegment"; import { COLOR_CSS_ID } from "./seismograph"; import { isDef, isNumArg, SVG_NS, validStartTime, validEndTime } from "./util"; import * as axisutil from "./axisutil"; import type { HandlebarsInput } from "./axisutil"; import type { Axis } from "d3-axis"; import type { ScaleLinear, NumberValue as d3NumberValue } from "d3-scale"; import type { Selection } from "d3-selection"; export const DEFAULT_TITLE = "{{#each seisDataList}}{{onlyChangesChannel ../seisDataList @index}} {{else}}No Data{{/each}}"; export const DEFAULT_XLABEL = "{{#each seisXData}}{{this.channelCode}} {{else}}No Data{{/each}}"; export const DEFAULT_YLABEL = "{{#each seisYData}}{{this.channelCode}} {{else}}No Data{{/each}}"; export const PARTICLE_MOTION_ELEMENT = "sp-particle-motion"; export const particleMotion_css = ` :host { display: block; min-height: 200px; height: 100%; } div.wrapper { min-height: 100px; height: 100%; width: 100%; } svg { height: 100%; width: 100%; min-height: 125px; min-width: 125px; z-index: 100; } svg text.title { font-size: larger; font-weight: bold; fill: black; color: black; } svg path.seispath { stroke: skyblue; fill: none; stroke-width: 1px; } `; export function createParticleMotionConfig( timeRange?: Interval | null, defaultSeisConfig?: SeismographConfig, ): SeismographConfig { let seisConfig; if (defaultSeisConfig) { seisConfig = defaultSeisConfig.clone(); } else { seisConfig = new SeismographConfig(); } seisConfig.title = DEFAULT_TITLE; if (isDef(timeRange)) { seisConfig.fixedTimeScale = timeRange; } seisConfig.xLabel = DEFAULT_XLABEL; seisConfig.yLabel = DEFAULT_YLABEL; seisConfig.xSublabelIsUnits = true; seisConfig.ySublabelIsUnits = true; seisConfig.margin.top = 20; seisConfig.margin.bottom = 45; seisConfig.margin.right = 40; seisConfig.margin.left = 40; return seisConfig; } /** * Particle motion plot. * * @param xSeisData x axis seismogram * @param ySeisData y axis seismogram * @param seismographConfig config, not all parameters are used in * particle motion plots. Can be null for defaults. */ export class ParticleMotion extends SeisPlotElement { plotId: number; _xSeisData: Array; _ySeisData: Array; width: number; height: number; outerWidth = -1; outerHeight = -1; xScale: ScaleLinear; xScaleRmean: ScaleLinear; xAxis: Axis; yScale: ScaleLinear; yScaleRmean: ScaleLinear; yAxis: Axis; g: Selection; static _lastID: number; constructor( xSeisData?: Array, ySeisData?: Array, seisConfig?: SeismographConfig, ) { if (!xSeisData) { xSeisData = []; } if (xSeisData instanceof Seismogram) { xSeisData = [SeismogramDisplayData.fromSeismogram(xSeisData)]; } if (!Array.isArray(xSeisData)) { xSeisData = [xSeisData]; } if (!ySeisData) { ySeisData = []; } if (!Array.isArray(ySeisData)) { ySeisData = [ySeisData]; } const seisData = xSeisData.concat(ySeisData); if (!seisConfig) { seisConfig = createParticleMotionConfig(); } super(seisData, seisConfig); this._xSeisData = xSeisData; this._ySeisData = ySeisData; this.addStyle(particleMotion_css); const lineColorsCSS = this.seismographConfig.createCSSForLineColors(); this.addStyle(lineColorsCSS, COLOR_CSS_ID); const wrapper = document.createElement("div"); wrapper.setAttribute("class", "wrapper"); const svgWrapped = wrapper.appendChild( document.createElementNS(SVG_NS, "svg"), ); this.getShadowRoot().appendChild(wrapper); const svg = d3select(svgWrapped); this.plotId = ++ParticleMotion._lastID; if (this.xSeisData.length !== this.ySeisData.length) { throw new Error( `xSeisData and ySeisData should have same length: ${this.xSeisData.length} !== ${this.ySeisData.length}`, ); } svg.attr("version", "1.1"); svg.classed("particleMotion", true); svg.attr("plotId", this.plotId); this.xScale = d3scaleLinear(); // yScale for axis (not drawing) that puts mean at 0 in center this.xScaleRmean = d3scaleLinear(); this.yScale = d3scaleLinear(); // yScale for axis (not drawing) that puts mean at 0 in center this.yScaleRmean = d3scaleLinear(); if (this.seismographConfig.isCenteredAmp()) { this.xAxis = d3axisBottom(this.xScaleRmean); this.yAxis = d3axisLeft(this.yScaleRmean); } else { this.xAxis = d3axisBottom(this.xScale); this.yAxis = d3axisLeft(this.yScale); } this.xAxis.ticks(this.seismographConfig.yAxisNumTickHint, this.seismographConfig.amplitudeFormat) .tickFormat( numberFormatWrapper(this.seismographConfig.amplitudeFormat), ); this.yAxis.ticks(this.seismographConfig.yAxisNumTickHint, this.seismographConfig.amplitudeFormat) .tickFormat( numberFormatWrapper(this.seismographConfig.amplitudeFormat), ); this.width = 100; this.height = 100; // for line ends to show direction of particle motion const arrow = svg.append("defs").append("marker"); arrow .attr("id", "arrow") .attr("markerWidth", "10") .attr("markerHeight", "10") .attr("refX", "0") .attr("refY", "3") .attr("orient", "auto") .attr("markerUnits", "strokeWidth"); arrow .append("path") .attr("d", "M0,0 L0,6 L9,3 z") .attr("stroke", "currentColor") .attr("fill", "currentColor"); this.g = svg .append("g") .attr( "transform", "translate(" + this.seismographConfig.margin.left + "," + this.seismographConfig.margin.top + ")", ); this.calcScaleDomain(); d3select(window).on("resize.particleMotion" + this.plotId, () => { if (this.checkResize()) { this.redraw(); } }); } get xSeisData(): Array { return this._xSeisData; } set xSeisData(xsdd: Array | SeismogramDisplayData) { if (Array.isArray(xsdd)) { this._xSeisData = xsdd; } else if (xsdd instanceof SeismogramDisplayData) { this._xSeisData = [xsdd]; } else { throw new Error(`Unknown data for xSeisData`); } this._seisDataList = this._xSeisData.concat(this._ySeisData); } get ySeisData(): Array { return this._ySeisData; } set ySeisData(ysdd: Array | SeismogramDisplayData) { if (Array.isArray(ysdd)) { this._ySeisData = ysdd; } else if (ysdd instanceof SeismogramDisplayData) { this._ySeisData = [ysdd]; } else { throw new Error(`Unknown data for xSeisData:`); } this._seisDataList = this._xSeisData.concat(this._ySeisData); } draw() { if (!this.isConnected) { return; } const wrapper = this.getShadowRoot().querySelector("div") as HTMLDivElement; const svgEl = wrapper.querySelector("svg") as SVGElement; if (!svgEl) { // svgEl is not def in particlemotion draw()? return; } const rect = svgEl.getBoundingClientRect(); let calcHeight = rect.height; if (rect.width !== this.outerWidth || rect.height !== this.outerHeight) { if ( isNumArg(this.seismographConfig.minHeight) && calcHeight < this.seismographConfig.minHeight ) { calcHeight = this.seismographConfig.minHeight; } if ( isNumArg(this.seismographConfig.maxHeight) && calcHeight > this.seismographConfig.maxHeight ) { calcHeight = this.seismographConfig.maxHeight; } this.calcWidthHeight(rect.width, calcHeight); } this.calcScaleDomain(); this.drawAxis(); const handlebarsInput = this.createHandlebarsInput(); axisutil.drawAxisLabels( svgEl, this.seismographConfig, this.height, this.width, handlebarsInput, ); this.drawParticleMotion(); } checkResize(): boolean { const wrapper = this.getShadowRoot().querySelector("div") as HTMLDivElement; const svgEl = wrapper.querySelector("svg") as SVGElement; const rect = svgEl.getBoundingClientRect(); if (rect.width !== this.outerWidth || rect.height !== this.outerHeight) { return true; } return false; } drawParticleMotion() { this.g.selectAll("g.particleMotion").remove(); if ( !this.xSeisData || this.xSeisData.length === 0 || !this.ySeisData || this.ySeisData.length === 0 ) { // no data yet return; } const lineG = this.g.append("g"); let xOrientCode = "X"; let yOrientCode = "Y"; if ( this.xSeisData[0].channelCode && this.xSeisData[0].channelCode.length > 2 ) { xOrientCode = this.xSeisData[0].channelCode.charAt(2); } if ( this.ySeisData[0].channelCode && this.ySeisData[0].channelCode.length > 2 ) { yOrientCode = this.ySeisData[0].channelCode.charAt(2); } lineG .classed("particleMotion", true) .classed("seisplotjsdata", true) .classed("seispath", true) .classed(this.xSeisData[0].codes(), true) .classed("orient" + xOrientCode + "_" + yOrientCode, true); let xSegments: Array; let ySegments: Array; for (let i = 0; i < this.xSeisData.length; i++) { xSegments = this.xSeisData[i].segments; ySegments = this.ySeisData[i].segments; xSegments.forEach((segX) => { ySegments.forEach((segY) => { this.drawParticleMotionForSegment(lineG, segX, segY); }); }); } } drawParticleMotionForSegment( lineG: Selection, segA: SeismogramSegment, segB: SeismogramSegment, ) { const timeRange = segA.timeRange.intersection(segB.timeRange); if (!isDef(timeRange)) { // no overlap return; } const s = validStartTime(timeRange); const e = validEndTime(timeRange); const idxA = segA.indexOfTime(s); const lastIdxA = segA.indexOfTime(e); const idxB = segB.indexOfTime(s); const lastIdxB = segB.indexOfTime(e); if (idxA === -1 || lastIdxA === -1 || idxB === -1 || lastIdxB === -1) { return; } const numPts = Math.min(lastIdxA - idxA, lastIdxB - idxB) + 1; const segmentG = lineG.append("g").classed("segment", true); const path = segmentG.selectAll("path").data([segA.y.slice(idxA, numPts)]); path.exit().remove(); path .enter() .append("path") .classed("seispath", true) .attr("marker-end", "url(#arrow)") .attr( "d", // @ts-expect-error no idea why typescript thinks dd is [number, number] when it is just number d3line() .curve(d3curveLinear) .x((dd) => { // @ts-expect-error no idea why typescript thinks dd is [number, number] when it is just number return this.xScale(dd); }) .y((d, i) => { return this.yScale(segB.yAtIndex(idxB + i)); }), ); } drawAxis() { const svgG = this.g; svgG.selectAll("g.axis").remove(); svgG .append("g") .attr("class", "axis axis--x") .attr("transform", "translate(0," + this.height + ")") .call(this.xAxis); svgG.append("g").attr("class", "axis axis--y").call(this.yAxis); } rescaleAxis() { const delay = 500; const yaxisG = this.g.select(".axis--y") as Selection< SVGGElement, unknown, null, undefined >; yaxisG .transition() .duration(delay / 2) .call(this.yAxis); const xaxisG = this.g.select(".axis--x") as Selection< SVGGElement, unknown, null, undefined >; xaxisG .transition() .duration(delay / 2) .call(this.xAxis); } calcScaleDomain() { let halfDomainDelta = 1; if (this.seismographConfig.fixedAmplitudeScale) { halfDomainDelta = (this.seismographConfig.fixedAmplitudeScale[1] - this.seismographConfig.fixedAmplitudeScale[0]) / 2; this.xScale.domain(this.seismographConfig.fixedAmplitudeScale).nice(); this.yScale.domain(this.seismographConfig.fixedAmplitudeScale).nice(); } else { let xMinMax = new MinMaxable(-1, 1); if (this.xSeisData) { xMinMax = findMinMaxOfSDD(this.xSeisData); } let yMinMax = new MinMaxable(-1, 1); if (this.ySeisData) { yMinMax = findMinMaxOfSDD(this.ySeisData); } halfDomainDelta = xMinMax.halfWidth; if (yMinMax.halfWidth > halfDomainDelta) { halfDomainDelta = yMinMax.halfWidth; } const xMid = xMinMax.middle; const yMid = yMinMax.middle; const xMinMaxArr = [xMid - halfDomainDelta, xMid + halfDomainDelta]; const yMinMaxArr = [yMid - halfDomainDelta, yMid + halfDomainDelta]; this.xScale.domain(xMinMaxArr).nice(); this.yScale.domain(yMinMaxArr).nice(); } const xNiceMinMax = this.xScale.domain(); const xHalfNice = (xNiceMinMax[1] - xNiceMinMax[0]) / 2; if (this.seismographConfig.isCenteredAmp()) { this.xScaleRmean.domain([-1 * xHalfNice, xHalfNice]); } else { this.xScaleRmean.domain(this.xScale.domain()); } const yNiceMinMax = this.yScale.domain(); const yHalfNice = (yNiceMinMax[1] - yNiceMinMax[0]) / 2; if (this.seismographConfig.isCenteredAmp()) { this.yScaleRmean.domain([-1 * yHalfNice, yHalfNice]); } else { this.yScaleRmean.domain(this.yScale.domain()); } this.rescaleAxis(); } calcWidthHeight(nOuterWidth: number, nOuterHeight: number) { const defHW = 200; this.outerWidth = nOuterWidth ? Math.max(defHW, nOuterWidth) : defHW; this.outerHeight = nOuterHeight ? Math.max(defHW, nOuterHeight) : defHW; this.height = this.outerHeight - this.seismographConfig.margin.top - this.seismographConfig.margin.bottom; this.width = this.outerWidth - this.seismographConfig.margin.left - this.seismographConfig.margin.right; this.height = Math.min(this.height, this.width); this.width = Math.min(this.height, this.width); this.xScale.range([0, this.width]); this.yScale.range([this.height, 0]); this.xScaleRmean.range([0, this.width]); this.yScaleRmean.range([this.height, 0]); } createHandlebarsInput(): HandlebarsInput { return { seisDataList: this._seisDataList, seisConfig: this._seismographConfig, seisXData: this.xSeisData, seisYData: this.ySeisData, }; } } // static ID for particle motion ParticleMotion._lastID = 0; customElements.define(PARTICLE_MOTION_ELEMENT, ParticleMotion);