import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import {
scaleLinear,
ScaleLinear,
Selection,
axisBottom,
Axis,
brushX,
BrushBehavior,
BrushSelection,
select,
NumberValue,
} from "d3";
import NightingaleElement, {
withDimensions,
withPosition,
withMargin,
withResizable,
withManager,
withHighlight,
} from "@nightingale-elements/nightingale-new-core";
const HANDLE_SIZE = 6;
@customElement("nightingale-navigation")
class NightingaleNavigation extends withManager(
withResizable(
withMargin(withPosition(withDimensions(withHighlight(NightingaleElement))))
)
) {
#x: ScaleLinear | null;
#dontDispatch: boolean;
#svg?: Selection;
#displaystartLabel?: Selection<
SVGTextElement,
unknown,
HTMLElement | null,
unknown
>;
#displayendLabel?: Selection<
SVGTextElement,
unknown,
HTMLElement | null,
unknown
>;
#axis?: Selection;
#brushG?: Selection;
#polygon?: Selection;
#xAxis?: Axis;
#viewport?: BrushBehavior;
#highlighted?: Selection<
SVGGElement,
unknown,
HTMLElement | SVGElement | null,
unknown
>;
#margins?: Selection<
SVGGElement,
unknown,
HTMLElement | SVGElement | null,
unknown
>;
#currentSelection: BrushSelection | null = null;
@property({ type: Number })
"ruler-start"?: number = 1;
@property({ type: Number })
"ruler-padding"?: number = 10;
@property({ type: Number })
"scale-factor"?: number = (this.length || 0) / 5 || 10;
@property({ type: Boolean })
"show-highlight"?: boolean = false;
constructor() {
super();
this.#x = null;
this.#dontDispatch = false;
}
private createNavRuler() {
const limit =
this.width - this["margin-right"] - (this["ruler-padding"] as number);
this.#x = scaleLinear()
.range([this["margin-left"] + (this["ruler-padding"] as number), limit])
.domain([
this["ruler-start"] as number,
(this["ruler-start"] as number) + (this.length || 1),
]);
this.#svg = select(this as unknown as NightingaleElement)
.selectAll("svg")
.attr("id", "")
.attr("width", this.width)
.attr("height", this.height);
this.#xAxis = axisBottom(this.#x);
this.#displaystartLabel = this.#svg
.append("text")
.attr("class", "start-label")
.attr("x", this["margin-left"])
.attr("y", this.height - this["margin-bottom"] - 2)
.style("font-family", "sans-serif")
.style("font-size", 12);
this.#displayendLabel = this.#svg
.append("text")
.attr("class", "end-label")
.attr("x", this.width - this["margin-right"])
.attr("y", this.height - this["margin-bottom"] - 2)
.attr("text-anchor", "end")
.style("font-family", "sans-serif")
.style("font-size", 12);
this.#axis = this.#svg
.append("g")
.attr("class", "x axis")
.call(this.#xAxis);
this.#viewport = brushX()
.extent([
[this["margin-left"] + (this["ruler-padding"] as number), 0],
[limit, this.height * 0.5 + HANDLE_SIZE / 2],
])
.handleSize(HANDLE_SIZE)
.on("end", ({ selection }) => {
// In case is a click outside the brush, reset brush to previous position
if (
selection === null &&
this.#currentSelection &&
this.#brushG &&
this.#viewport
) {
this.#brushG.call(this.#viewport.move, this.#currentSelection);
}
})
.on("brush", ({ selection, transform }) => {
if (selection && this.#x) {
this.#currentSelection = selection;
this["display-start"] =
Math.round(this.#x.invert(selection[0]) * 100) / 100;
this["display-end"] =
Math.round(this.#x.invert(selection[1]) * 100) / 100;
if (!this.#dontDispatch)
this.dispatchEvent(
new CustomEvent("change", {
detail: {
"display-end": this["display-end"],
"display-start": this["display-start"],
extra: { transform },
},
bubbles: true,
cancelable: true,
})
);
this.updateLabels();
this.updatePolygon();
}
});
this.#brushG = this.#svg.append("g").attr("class", "brush");
if (limit > 0) {
this.#brushG.call(this.#viewport);
this.#brushG.call(this.#viewport.move, [
this.#x(this.getStart()),
this.#x(this.getEnd()),
]);
}
this.#polygon = this.#svg
.append("polygon")
.attr("class", "zoom-polygon")
.attr("fill", "#777")
.attr("fill-opacity", "0.3");
this.#margins = this.#svg.append("g").attr("class", "margin");
this.createHighlight();
this.renderD3();
}
private createHighlight() {
if (!this.#svg) return;
this.#highlighted = this.#svg.append("g").attr("class", "highlighted");
this.updateHighlight();
}
override onDimensionsChange() {
if (!this.#x) return;
this.#x.range([
this["margin-left"] + (this["ruler-padding"] as number),
this.width - this["margin-right"] - (this["ruler-padding"] as number),
]);
this.#svg?.attr("width", this.width);
this.#svg?.attr("height", this.height);
this.#viewport?.extent([
[this["margin-left"] + (this["ruler-padding"] as number), 0],
[
this.width - this["margin-right"] - (this["ruler-padding"] as number),
this.height * 0.5 + HANDLE_SIZE / 2,
],
]);
if (this.#viewport) this.#brushG?.call(this.#viewport);
}
override render() {
return html``;
}
override updated(changedProperties: Map) {
this.renderD3();
super.updated(changedProperties);
}
override firstUpdated() {
this.createNavRuler();
}
renderD3() {
if (this.#x && this.#axis && this.#xAxis && this.#viewport) {
this.#x.domain([
this["ruler-start"] as number,
(this["ruler-start"] as number) + (this.length || 1) - 1,
]);
this.#axis.call(this.#xAxis);
this.updatePolygon();
this.updateLabels();
const position: BrushSelection = [
this.#x(this.getStart()),
this.#x(this.getEnd()),
];
if (
this.#brushG &&
Number.isFinite(position[0]) &&
Number.isFinite(position[1]) &&
position[0] >= 0 &&
position[1] >= 0 &&
position[0] < position[1]
) {
this.#dontDispatch = true;
this.#brushG.call(this.#viewport.move, position);
this.#dontDispatch = false;
}
this.updateHighlight();
this.renderMarginOnGroup(this.#margins);
}
}
locate(start: number, end: number) {
if (this.#brushG && this.#viewport && this.#x)
this.#brushG.call(this.#viewport.move, [this.#x(start), this.#x(end)]);
}
zoomOut() {
this.locate(
Math.max(
(this["ruler-start"] as number) || 1,
this.getStart() - (this["scale-factor"] as number)
),
Math.min(
(this.length || 1) + (this["ruler-start"] as number) - 1,
this.getEnd() + (this["scale-factor"] as number)
)
);
}
zoomIn() {
const newStart = Math.min(
this.getStart() + (this["scale-factor"] as number),
this.getEnd() - 1
);
this.locate(
newStart,
Math.max(this.getEnd() - (this["scale-factor"] as number), newStart + 1)
);
}
protected updateHighlight() {
if (!this.#highlighted) return;
// Scale to match the range of the tracks [1,length+1]
const s1 = scaleLinear()
.domain([
this["display-start"] || 1,
(this["display-end"] || this.length || 1) + 1,
])
.range([this["margin-left"], this.width - this["margin-right"]]);
// Scale to match the range of the navigation brush [1,length]
const s2 = scaleLinear()
.domain([
this["ruler-start"] as number,
(this["ruler-start"] as number) + (this.length || 1),
])
.range([
this["margin-left"] + (this["ruler-padding"] as number),
this.width - this["margin-right"] - (this["ruler-padding"] as number),
]);
// Highlight Polygon
const highlighs = this.#highlighted
.selectAll<
SVGPolygonElement,
{
start: number;
end: number;
}[]
>("polygon")
.data(this["show-highlight"] ? this.highlightedRegion.segments : []);
highlighs
.enter()
.append("polygon")
.attr("class", "highlight-polygon")
.style("pointer-events", "none")
.merge(highlighs)
.attr("fill", this["highlight-color"])
.attr("points", (segment) => {
const start = Math.max(1, segment.start);
const end = Math.min(this.length || 1, segment.end) + 1;
return `${s2(start)},${this.height / 2}
${s2(end)},${this.height / 2}
${s1(end)},${this.height}
${s1(start)},${this.height}`;
});
highlighs.exit().remove();
const highlighsRect = this.#highlighted
.selectAll<
SVGRectElement,
{
start: number;
end: number;
}[]
>("rect")
.data(this["show-highlight"] ? this.highlightedRegion.segments : []);
// Highlight Rectangle
highlighsRect
.enter()
.append("rect")
.attr("class", "highlight-rectangle")
.style("pointer-events", "none")
.merge(highlighsRect)
.attr("fill", this["highlight-color"])
.attr("x", (segment) => s2(Math.max(1, segment.start)))
.attr(
"width",
(segment) =>
s2(Math.min(this.length || 1, segment.end) + 1) -
s2(Math.max(1, segment.start))
)
.attr("y", this["margin-top"])
.attr("height", this.height / 2);
highlighsRect.exit().remove();
}
private updateLabels() {
if (this.#displaystartLabel)
this.#displaystartLabel
.attr("x", this["margin-left"])
.text(Math.round(this.getStart()));
if (this.#displayendLabel)
this.#displayendLabel
.attr("x", this.width - this["margin-right"])
.text(Math.round(this.getEnd()));
}
private updatePolygon() {
if (this.#polygon && this.#x)
this.#polygon.attr(
"points",
`${this.#x(this.getStart())},${this.height / 2}
${this.#x(this.getEnd())},${this.height / 2}
${this.width - this["margin-right"]},${this.height}
${this["margin-left"]},${this.height}`
);
}
private getStart(): number {
return this["display-start"] || 1;
}
private getEnd(): number {
return (
((this["display-end"] || 0) > 0 ? this["display-end"] : this.length) || 1
);
}
}
export default NightingaleNavigation;