public render()

in gui/frontend/src/components/graphs/LineGraphRenderer.ts [57:271]


    public render(target: SVGSVGElement, config: ILineGraphConfiguration): void {
        if (!config.data) {
            return;
        }

        this.tooltip = config.tooltip;
        this.data = config.data;

        const width = config.transformation?.width ?? 1200;
        const height = config.transformation?.height ?? 900;
        const x = config.transformation?.x ?? "0";
        const y = config.transformation?.y ?? "0";

        const marginTop = config.marginTop ?? 20;
        const marginRight = config.marginRight ?? 30;
        const marginBottom = config.marginBottom ?? 30;
        const marginLeft = config.marginLeft ?? 40;
        const strokeWidth = config.strokeWidth ?? 1.5;
        const strokeLinejoin = config.strokeLinejoin ?? "round";
        const strokeLinecap = config.strokeLinecap ?? "round";

        this.xValues = d3.map(config.data, (datum) => {
            return datum.xValue;
        });

        this.yValues = d3.map(config.data, (datum) => {
            return datum.yValue;
        });

        // Compute which data points are considered defined.
        const isDefined = (_datum: IXYDatum, index: number) => {
            return this.xValues[index] !== undefined && this.yValues[index] !== undefined;
        };

        const definedValues = d3.map(config.data, isDefined);

        // Compute default domains.
        let xDomain = config.xDomain;
        if (!xDomain) {
            const temp = d3.extent(this.xValues);
            if (temp[0] !== undefined && temp[1] !== undefined) {
                if (temp[0] instanceof Date) {
                    xDomain = temp as [Date, Date];
                } else {
                    xDomain = temp as [number, number];
                }
            } else {
                xDomain = [0, 0];
            }
        }

        let yDomain = config.yDomain;
        if (!yDomain) {
            const index = d3.maxIndex(this.yValues);
            yDomain = [0, this.yValues[index] ?? 0];
        }

        // Construct scales and axes.
        // TODO: make the scale types configurable without exposing D3 in the scripting environment.
        const xRange = [marginLeft, width - marginRight];

        // TODO: support a linear x axis too.
        /*if (typeof xDomain[0] === "number") {
            this.xScale = d3.scaleLinear(xDomain, xRange);
        } else {
            this.xScale = d3.scaleUtc(xDomain, xRange);
        }*/
        this.xScale = d3.scaleUtc(xDomain, xRange);
        this.xScale.clamp(true);

        const yRange = [height - marginBottom, marginTop];
        this.yScale = d3.scaleLinear(yDomain, yRange);

        let tickCount = config.xTickCount ?? width / 80;
        const xAxis = d3.axisBottom(this.xScale as d3.AxisScale<Date>).ticks(tickCount).tickSizeOuter(0);
        tickCount = config.yTickCount ?? height / 40;
        const yAxis = d3.axisLeft(this.yScale).ticks(tickCount, config.yFormat);

        if (config.yFormat) {
            if (typeof config.yFormat === "string") {
                yAxis.tickFormat(d3.format(config.yFormat));
            } else {
                yAxis.tickFormat(config.yFormat);
            }
        }

        // Compute titles.
        if (config.xTitle) {
            const objects = d3.map(config.data, (d) => {
                return d;
            });

            this.titleGenerator = (i: number) => {
                return config.xTitle!(objects[i], i, config.data!);
            };

        } else {
            const formatDate = this.xScale.tickFormat(undefined, "%b %-d, %Y");
            const formatValue = this.yScale.tickFormat(undefined, "0.2f");
            this.titleGenerator = (i: number) => {
                const xValue = this.xValues[i];
                if (!this.yValues[i]) {
                    return "";
                }

                if (typeof xValue === "number") {
                    return `${formatValue(xValue)}\n${formatValue(this.yValues[i]!)}`;
                } else {
                    return `${formatDate(xValue)}\n${formatValue(this.yValues[i]!)}`;
                }
            };
        }

        const hostSvg = d3.select<SVGSVGElement, IXYDatum>(target);
        let root;
        try {
            root = hostSvg.select<SVGSVGElement>(`#${config.id}`);
        } catch (reason) {
            root = hostSvg.select<SVGSVGElement>("__invalid__");
        }

        // TODO: instead of completely replacing the graph animate it to the new values.
        root.remove();
        root = hostSvg.append("svg").attr("id", config.id).style("pointer-events", "bounding-box");

        root.on("pointerenter", this.pointerEnter)
            .on("pointermove", this.pointerMoved)
            .on("pointerleave", this.pointerLeft)
            .on("touchstart", (event: MouseEvent) => {
                return event.preventDefault();
            });


        root.attr("x", `${x}`)
            .attr("y", `${y}`)
            .attr("overflow", "visible")
            .attr("width", width)
            .attr("height", height);

        root.append("g")
            .attr("transform", `translate(0,${height - marginBottom})`)
            .attr("id", "xAxis")
            .call(xAxis);

        root.append("g")
            .attr("transform", `translate(${marginLeft},0)`)
            .attr("id", "yAxis")
            .call(yAxis)
            .call((g) => {
                return g.select(".domain").remove();
            })
            .call((g) => {
                return g.selectAll(".tick line").clone()
                    .attr("x2", width - marginLeft - marginRight)
                    .attr("stroke-opacity", 0.1);
            })
            .call((g) => {
                return g.append("text")
                    .attr("x", -marginLeft)
                    .attr("y", 10)
                    .attr("fill", "currentColor")
                    .attr("text-anchor", "start")
                    .text(config.yLabel ?? "");
            });

        const curve = config.curve ? this.curveMap.get(config.curve) ?? d3.curveBasis : d3.curveBasis;
        const lineGenerator = d3.line<number>()
            .defined((i) => {
                return definedValues[i];
            })
            .curve(curve)
            .x((i) => {
                return this.xScale(this.xValues[i]);
            })
            .y((i) => {
                return this.yScale(this.yValues[i] ?? 0);
            });

        const groupValues = d3.map(config.data, (datum) => {
            return datum.group;
        });
        const groupDomain = new d3.InternSet(groupValues);

        // Remove any data which is not presented in a group.
        const filteredData = d3.range(this.xValues.length).filter((i) => {
            return groupDomain.has(groupValues[i]);
        });

        root.append("g")
            .attr("fill", "none")
            .attr("stroke", "currentColor")
            .attr("stroke-width", strokeWidth)
            .attr("stroke-linejoin", strokeLinejoin)
            .attr("stroke-linecap", strokeLinecap)
            .selectAll("path")
            .data(d3.group(filteredData, (i) => {
                return groupValues[i];
            }))
            .join("path")
            .attr("stroke", (datum, index) => {
                return config.colors
                    ? config.colors[index]
                    : this.colors(index.toString());
            })
            .attr("d", ([, indexes]) => {
                return lineGenerator(indexes);
            });

        this.tooltipElement = root.append<SVGGElement>("g")
            .style("pointer-events", "none");
        this.tooltipElement.append("path")
            .attr("fill", "var(--tooltip-background)")
            .attr("stroke", "var(--tooltip-border)")
            .attr("color", "var(--tooltip-foreground");
    }