Source: paperitems/rulermeasurement.mjs

/**
 * OpenSeadragon paperjs overlay plugin based on paper.js
 * @version 0.5.0
 *
 * Copyright (c) 2022-2026, Thomas Pearce
 * All rights reserved.
 */

import { MultiLinestring } from './multilinestring.mjs';
import { paper } from '../paperjs.mjs';
import { clampDecimals, normalizeRoundingMode, formatDecimal } from '../utils/measurementFormat.mjs';

// Segment group layout: exactly 3 children (must match ruler.mjs): halo, path, labelGroup ([strokeLabel, fillLabel])
const SEGMENT_HALO = 0;
const SEGMENT_PATH = 1;
const SEGMENT_LABEL_GROUP = 2;

const RULER_LABEL_STROKE_PX = 3;
const RULER_LABEL_GAP_PX = 4; // Gap between line and label in screen pixels (must match ruler.mjs)
const DEFAULT_STROKE_WIDTH_PX = 2;
const DEFAULT_HALO_EXTRA_PX = 2;
const DEFAULT_LABEL_FONT_SIZE = 12;
const DEFAULT_DECIMALS = 2;
const DEFAULT_ROUNDING_MODE = 'round';

/**
 * Compute placement center for label offset above segment (constant pixel gap, zoom-aware).
 * Same math as ruler tool's _computeLabelPlacementCenter; used only by the item.
 * @param {paper.PointText} label - label with bounds set (after applyRescale)
 * @param {paper.Point} p1 - segment start point
 * @param {paper.Point} p2 - segment end point
 * @param {paper.Point} midpoint - segment midpoint
 * @param {number} gapPixels - gap in screen pixels (e.g. RULER_LABEL_GAP_PX)
 * @returns {paper.Point} placement center (midpoint if segment too short, otherwise offset above)
 */
function computeLabelPlacementCenter(label, p1, p2, midpoint, gapPixels) {
    const segmentDir = p2.subtract(p1);
    const segmentLength = segmentDir.length;
    if (segmentLength < 1e-6) return midpoint;

    const normalizedDir = segmentDir.normalize();
    const normal = new paper.Point(normalizedDir.y, -normalizedDir.x);
    const zoomFactor = label.view.scaling.x * label.layer.scaling.x;
    const gapPaper = gapPixels / zoomFactor;
    const labelBounds = (label.getInternalBounds && label.getInternalBounds()) ? label.getInternalBounds() : label.bounds;
    const labelHeight = labelBounds ? labelBounds.height : 0;
    const offset = labelHeight / 2 + gapPaper;
    return midpoint.add(normal.multiply(offset));
}

/**
 * Build one 3-child segment group from a path and saved measurement properties.
 * Label group counter-rotates with view (upright like PointText). Used when loading from GeoJSON.
 * @param {paper.Path} path - existing path (two segments)
 * @param {paper.Group} parentGroup - parent that will receive the segment (must be in project so labelGroup.view exists)
 * @param {Object} props - geometry.properties (lengths, units, strokeWidthPixels, haloExtraPixels, labelFontSize, etc.)
 * @param {number} index - segment index
 * @returns {paper.Group}
 */
function buildSegmentGroupFromPath(path, parentGroup, props, index) {
    const strokeWidthPixels = props.strokeWidthPixels != null ? props.strokeWidthPixels : DEFAULT_STROKE_WIDTH_PX;
    const haloExtraPixels = props.haloExtraPixels != null ? props.haloExtraPixels : DEFAULT_HALO_EXTRA_PX;
    const labelFontSize = props.labelFontSize != null ? props.labelFontSize : DEFAULT_LABEL_FONT_SIZE;
    const units = props.units != null ? props.units : 'px';
    const decimals = clampDecimals(props.decimals, DEFAULT_DECIMALS);
    const roundingMode = normalizeRoundingMode(props.roundingMode, DEFAULT_ROUNDING_MODE);
    const lengths = props.lengths || [];
    const lengthDisplay = lengths[index] != null
        ? (typeof lengths[index] === 'number' ? formatDecimal(lengths[index], decimals, roundingMode) : String(lengths[index])) + ' ' + units
        : '—';

    const p1 = path.segments[0].point.clone();
    const p2 = path.segments[1].point.clone();
    const midpoint = p1.add(p2).divide(2);
    const fillColor = path.strokeColor || new paper.Color('black');

    const haloPath = new paper.Path([p1.clone(), p2.clone()]);
    haloPath.strokeColor = 'white';
    haloPath.strokeCap = 'round';
    haloPath.strokeJoin = 'round';
    const haloWidthPixels = strokeWidthPixels + haloExtraPixels;
    haloPath.rescale = { strokeWidth: haloWidthPixels };

    path.strokeCap = 'round';
    path.strokeJoin = 'round';
    path.rescale = path.rescale || {};
    path.rescale.strokeWidth = strokeWidthPixels;

    const strokeLabel = new paper.PointText({
        point: new paper.Point(0, 0),
        content: lengthDisplay,
        fontSize: labelFontSize,
        fillColor: null,
        strokeColor: 'white',
        justification: 'center',
    });
    strokeLabel.rescale = {
        fontSize: (z) => labelFontSize / z,
        strokeWidth: (z) => RULER_LABEL_STROKE_PX / z,
    };

    const fillLabel = new paper.PointText({
        point: new paper.Point(0, 0),
        content: lengthDisplay,
        fontSize: labelFontSize,
        fillColor,
        justification: 'center',
    });
    fillLabel.rescale = { fontSize: (z) => labelFontSize / z };

    const labelGroup = new paper.Group();
    labelGroup.pivot = new paper.Point(0, 0);
    labelGroup.applyMatrix = true;
    labelGroup.addChild(strokeLabel);
    labelGroup.addChild(fillLabel);

    const group = new paper.Group();
    group.addChild(haloPath);
    group.addChild(path);
    group.addChild(labelGroup);

    parentGroup.addChild(group);
    if (fillLabel.applyRescale) fillLabel.applyRescale();
    if (strokeLabel.applyRescale) strokeLabel.applyRescale();
    const labelHeight = (fillLabel.getInternalBounds && fillLabel.getInternalBounds()) ? fillLabel.getInternalBounds().height : (fillLabel.bounds ? fillLabel.bounds.height : 0);
    if (labelHeight > 0) {
        fillLabel.point = new paper.Point(0, labelHeight / 2);
        strokeLabel.point = new paper.Point(0, labelHeight / 2);
        labelGroup.pivot = new paper.Point(0, labelHeight / 2);
    }
    // Position and upright: same pass as PointText (group is in project after addChild)
    if (labelGroup.view) {
        labelGroup.position = computeLabelPlacementCenter(fillLabel, p1, p2, midpoint, RULER_LABEL_GAP_PX).clone();
        function handleFlip() {
            const angle = labelGroup.view.getFlipped() ? labelGroup.view.getRotation() : 180 - labelGroup.view.getRotation();
            labelGroup.rotate(-angle);
            labelGroup.scale(-1, 1);
            labelGroup.rotate(angle);
        }
        if (labelGroup.view.getFlipped()) {
            handleFlip();
        }
        const offsetAngle = labelGroup.view.getFlipped() ? 180 - labelGroup.view.getRotation() : -labelGroup.view.getRotation();
        labelGroup.rotate(offsetAngle);
        labelGroup.view.on('rotate', (ev) => {
            const angle = -ev.rotatedBy;
            labelGroup.rotate(angle);
        });
        labelGroup.view.on('flip', () => {
            handleFlip();
        });
    }
    return group;
}

/**
 * Represents a ruler/measurement annotation: MultiLineString with subtype 'Measurement',
 * storing units, measured lengths, and display settings in geometry.properties.
 * @class
 * @extends MultiLinestring
 */
class RulerMeasurement extends MultiLinestring {
    /**
     * @param {Object} geoJSON - GeoJSON feature with geometry.type === 'MultiLineString', geometry.properties.subtype === 'Measurement'
     */
    constructor(geoJSON) {
        super(geoJSON);

        const geom = geoJSON.geometry;
        const props = geom.properties || {};
        const grp = this.paperItem;

        if (grp.children.length === 0) {
            grp.data.ruler = {
                units: props.units != null ? props.units : 'px',
                unitsPerPixel: props.unitsPerPixel != null ? props.unitsPerPixel : 1,
                decimals: clampDecimals(props.decimals, DEFAULT_DECIMALS),
                roundingMode: normalizeRoundingMode(props.roundingMode, DEFAULT_ROUNDING_MODE),
                strokeWidthPixels: props.strokeWidthPixels != null ? props.strokeWidthPixels : DEFAULT_STROKE_WIDTH_PX,
                haloExtraPixels: props.haloExtraPixels != null ? props.haloExtraPixels : DEFAULT_HALO_EXTRA_PX,
                labelFontSize: props.labelFontSize != null ? props.labelFontSize : DEFAULT_LABEL_FONT_SIZE,
            };
        } else {
            const children = grp.children.slice();
            grp.removeChildren();
            for (let i = 0; i < children.length; i++) {
                const path = children[i];
                buildSegmentGroupFromPath(path, grp, props, i);
            }

            grp.data.ruler = {
                units: props.units != null ? props.units : 'px',
                unitsPerPixel: props.unitsPerPixel != null ? props.unitsPerPixel : 1,
                decimals: clampDecimals(props.decimals, DEFAULT_DECIMALS),
                roundingMode: normalizeRoundingMode(props.roundingMode, DEFAULT_ROUNDING_MODE),
                strokeWidthPixels: props.strokeWidthPixels != null ? props.strokeWidthPixels : DEFAULT_STROKE_WIDTH_PX,
                haloExtraPixels: props.haloExtraPixels != null ? props.haloExtraPixels : DEFAULT_HALO_EXTRA_PX,
                labelFontSize: props.labelFontSize != null ? props.labelFontSize : DEFAULT_LABEL_FONT_SIZE,
            };
        }

        // Reposition labels when zoom changes so the screen-pixel gap stays constant.
        const view = this.paperItem.project && this.paperItem.project.view;
        if (view) {
            this._zoomChangedHandler = () => this.refreshSegmentLabels();
            view.on('zoom-changed', this._zoomChangedHandler);
            this.paperItem.on('removed', () => view.off('zoom-changed', this._zoomChangedHandler));
        }
    }

    static supportsGeoJSONType(type, subtype = null) {
        return type != null && type.toLowerCase() === 'multilinestring' &&
            subtype != null && subtype.toLowerCase() === 'measurement';
    }

    getGeoJSONType() {
        return {
            type: 'MultiLineString',
            subtype: 'Measurement',
        };
    }

    /**
     * Update label content and position for one segment group (content from path length + item.data.ruler).
     * Used by the ruler tool when refreshing a single segment (e.g. after drag) or all segments.
     * @param {paper.Group} segmentGroup - group with [halo, path, labelGroup] where labelGroup has [strokeLabel, fillLabel]
     */
    refreshSegmentLabel(segmentGroup) {
        if (!segmentGroup || segmentGroup.children.length !== 3) return;
        const path = segmentGroup.children[SEGMENT_PATH];
        const labelGroup = segmentGroup.children[SEGMENT_LABEL_GROUP];
        if (!path || path.segments.length < 2 || !labelGroup || labelGroup.children.length < 2) return;
        const strokeLabel = labelGroup.children[0];
        const fillLabel = labelGroup.children[1];
        const p1 = path.segments[0].point;
        const p2 = path.segments[1].point;
        const distance = p1.getDistance(p2);
        const midpoint = p1.add(p2).divide(2);
        const ruler = this.paperItem.data.ruler || {};
        const unitsPerPixel = ruler.unitsPerPixel != null ? ruler.unitsPerPixel : 1;
        const units = ruler.units != null ? ruler.units : 'px';
        const decimals = clampDecimals(ruler.decimals, DEFAULT_DECIMALS);
        const roundingMode = normalizeRoundingMode(ruler.roundingMode, DEFAULT_ROUNDING_MODE);
        const labelFontSize = ruler.labelFontSize != null ? ruler.labelFontSize : DEFAULT_LABEL_FONT_SIZE;
        const lengthDisplay = formatDecimal(distance * unitsPerPixel, decimals, roundingMode) + ' ' + units;

        fillLabel.justification = 'center';
        fillLabel.fillColor = path.strokeColor || new paper.Color('black');
        fillLabel.strokeColor = null;
        fillLabel.content = lengthDisplay;
        fillLabel.rescale = fillLabel.rescale || {};
        fillLabel.rescale.fontSize = (z) => labelFontSize / z;
        delete fillLabel.rescale.strokeWidth;
        strokeLabel.justification = 'center';
        strokeLabel.strokeColor = 'white';
        strokeLabel.fillColor = null;
        strokeLabel.content = lengthDisplay;
        strokeLabel.rescale = strokeLabel.rescale || {};
        strokeLabel.rescale.fontSize = (z) => labelFontSize / z;
        strokeLabel.rescale.strokeWidth = (z) => RULER_LABEL_STROKE_PX / z;
        if (fillLabel.applyRescale) fillLabel.applyRescale();
        if (strokeLabel.applyRescale) strokeLabel.applyRescale();

        const labelHeight = (fillLabel.getInternalBounds && fillLabel.getInternalBounds()) ? fillLabel.getInternalBounds().height : (fillLabel.bounds ? fillLabel.bounds.height : 0);
        if (labelHeight > 0) {
            fillLabel.point = new paper.Point(0, labelHeight / 2);
            strokeLabel.point = new paper.Point(0, labelHeight / 2);
            labelGroup.pivot = new paper.Point(0, labelHeight / 2);
        }
        if (fillLabel.view) {
            labelGroup.position = computeLabelPlacementCenter(fillLabel, p1, p2, midpoint, RULER_LABEL_GAP_PX).clone();
        }
    }

    /**
     * Update label content and position for all segment groups. Used by the ruler tool when units/settings change.
     */
    refreshSegmentLabels() {
        const item = this.paperItem;
        if (!item || !item.layer || !item.children.length) return;
        item.children.forEach((child) => {
            if (child instanceof paper.Group && child.children.length === 3) {
                this.refreshSegmentLabel(child);
            }
        });
    }

    /**
     * Return geometry.properties (no subtype; base toGeoJSONGeometry adds it from getGeoJSONType).
     */
    getProperties() {
        const item = this.paperItem;
        const base = super.getProperties();
        const ruler = item.data.ruler || {};
        const unitsPerPixel = ruler.unitsPerPixel != null ? ruler.unitsPerPixel : 1;
        const units = ruler.units != null ? ruler.units : 'px';
        const decimals = clampDecimals(ruler.decimals, DEFAULT_DECIMALS);
        const roundingMode = normalizeRoundingMode(ruler.roundingMode, DEFAULT_ROUNDING_MODE);

        const lengths = [];
        let total = 0;
        for (let i = 0; i < item.children.length; i++) {
            const path = this._getPathFromChild(item.children[i]);
            if (path.segments.length >= 2) {
                const d = path.segments[0].point.getDistance(path.segments[1].point);
                const lengthDisplay = d * unitsPerPixel;
                lengths.push(lengthDisplay);
                total += lengthDisplay;
            } else {
                lengths.push(0);
            }
        }

        return {
            ...base,
            lengths,
            length: total,
            units,
            unitsPerPixel,
            decimals,
            roundingMode,
            strokeWidthPixels: ruler.strokeWidthPixels != null ? ruler.strokeWidthPixels : DEFAULT_STROKE_WIDTH_PX,
            haloExtraPixels: ruler.haloExtraPixels != null ? ruler.haloExtraPixels : DEFAULT_HALO_EXTRA_PX,
            labelFontSize: ruler.labelFontSize != null ? ruler.labelFontSize : DEFAULT_LABEL_FONT_SIZE,
        };
    }
}

export { RulerMeasurement };