import { get, type Readable } from 'svelte/store';
import {
    type GeoJSONSource,
    type FilterSpecification,
    type MapLayerMouseEvent,
    type MapLayerTouchEvent,
    Point,
} from 'maplibre-gl';
import { map } from '$lib/components/map/map';
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
import {
    ListTrackSegmentItem,
    ListWaypointItem,
    ListWaypointsItem,
    ListTrackItem,
    ListFileItem,
    ListRootItem,
} from '$lib/components/file-list/file-list';
import { getClosestLinePoint, getElevation, loadSVGIcon } from '$lib/utils';
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
import { MapPin, Square } from 'lucide-static';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import { selection } from '$lib/logic/selection';
import { settings } from '$lib/logic/settings';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { fileActionManager } from '$lib/logic/file-action-manager';
import { fileActions } from '$lib/logic/file-actions';
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { gpxColors } from '$lib/components/map/gpx-layer/gpx-layers';
import { distance } from 'gpx';

// A point-to-point distance is highlighted as a likely GPS gap when it exceeds both:
// - GAP_RELATIVE_MULTIPLIER times the median of the last GAP_BASELINE_WINDOW normal distances
// - GAP_MIN_ABSOLUTE_DISTANCE meters (to avoid flagging noise when the recent pace is very slow)
const GAP_RELATIVE_MULTIPLIER = 3;
const GAP_MIN_ABSOLUTE_DISTANCE = 15;
const GAP_BASELINE_WINDOW = 8;
const GAP_MIN_BASELINE_SAMPLES = 3;

function median(values: number[]): number {
    const sorted = [...values].sort((a, b) => a - b);
    const mid = Math.floor(sorted.length / 2);
    return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}

// Always visually distinct from the track's own color, whatever that color is
function invertColor(hex: string): string {
    const r = parseInt(hex.slice(1, 3), 16);
    const g = parseInt(hex.slice(3, 5), 16);
    const b = parseInt(hex.slice(5, 7), 16);
    const toHex = (c: number) => (255 - c).toString(16).padStart(2, '0');
    return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}

const colors = [
    '#ff0000',
    '#0000ff',
    '#46e646',
    '#00ccff',
    '#ff9900',
    '#ff00ff',
    '#ffff32',
    '#288228',
    '#9933ff',
    '#50f0be',
    '#8c645a',
];

const colorCount: { [key: string]: number } = {};
for (let color of colors) {
    colorCount[color] = 0;
}

// Get the color with the least amount of uses
function getColor(fileId: string) {
    let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b));
    colorCount[color]++;
    gpxColors.update((colors) => {
        colors.set(fileId, color);
        return colors;
    });
    return color;
}

function replaceColor(fileId: string, oldColor: string, newColor: string) {
    if (colorCount.hasOwnProperty(oldColor)) {
        colorCount[oldColor]--;
    }
    colorCount[newColor]++;
    gpxColors.update((colors) => {
        colors.set(fileId, newColor);
        return colors;
    });
}

function removeColor(fileId: string, color: string) {
    if (colorCount.hasOwnProperty(color)) {
        colorCount[color]--;
    }
    gpxColors.update((colors) => {
        colors.delete(fileId);
        return colors;
    });
}

export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) {
    let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
    return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
    ${
        layerColor
            ? Square.replace('width="24"', 'width="12"')
                  .replace('height="24"', 'height="12"')
                  .replace('stroke="currentColor"', 'stroke="SteelBlue"')
                  .replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"')
                  .replace('fill="none"', `fill="${layerColor}"`)
            : ''
    }
    ${MapPin.replace('width="24"', '')
        .replace('height="24"', '')
        .replace('stroke="currentColor"', '')
        .replace('path', `path fill="#3fb1ce" stroke="SteelBlue" stroke-width="1"`)
        .replace(
            'circle',
            `circle fill="${symbolSvg ? 'none' : 'white'}" stroke="${symbolSvg ? 'none' : 'white'}" stroke-width="2"`
        )} 
    ${
        symbolSvg
            ?.replace('width="24"', 'width="10"')
            .replace('height="24"', 'height="10"')
            .replace('stroke="currentColor"', 'stroke="white"')
            .replace('stroke-width="2"', 'stroke-width="2.5" x="7" y="5"') ?? ''
    }
    </svg>`;
}

const { directionMarkers, gapHighlight, treeFileView, defaultOpacity, defaultWidth } = settings;

export class GPXLayer {
    fileId: string;
    file: Readable<GPXFileWithStatistics | undefined>;
    layerColor: string;
    selected: boolean = false;
    currentWaypointData: GeoJSON.FeatureCollection | null = null;
    draggedWaypointIndex: number | null = null;
    draggingStartingPosition: Point = new Point(0, 0);
    unsubscribe: Function[] = [];

    updateBinded: () => void = this.update.bind(this);
    layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
    layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
    layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this);
    layerOnClickBinded: (e: MapLayerMouseEvent) => void = this.layerOnClick.bind(this);
    layerOnContextMenuBinded: (e: MapLayerMouseEvent) => void = this.layerOnContextMenu.bind(this);
    waypointLayerOnMouseEnterBinded: (e: MapLayerMouseEvent) => void =
        this.waypointLayerOnMouseEnter.bind(this);
    waypointLayerOnMouseLeaveBinded: (e: MapLayerMouseEvent) => void =
        this.waypointLayerOnMouseLeave.bind(this);
    waypointLayerOnClickBinded: (e: MapLayerMouseEvent) => void =
        this.waypointLayerOnClick.bind(this);
    waypointLayerOnMouseDownBinded: (e: MapLayerMouseEvent) => void =
        this.waypointLayerOnMouseDown.bind(this);
    waypointLayerOnTouchStartBinded: (e: MapLayerTouchEvent) => void =
        this.waypointLayerOnTouchStart.bind(this);
    waypointLayerOnMouseMoveBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
        this.waypointLayerOnMouseMove.bind(this);
    waypointLayerOnMouseUpBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
        this.waypointLayerOnMouseUp.bind(this);

    constructor(fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
        this.fileId = fileId;
        this.file = file;
        this.layerColor = getColor(fileId);
        this.unsubscribe.push(
            map.subscribe(($map) => {
                if ($map) {
                    $map.on('style.load', this.updateBinded);
                    this.update();
                }
            })
        );
        this.unsubscribe.push(file.subscribe(this.updateBinded));
        this.unsubscribe.push(
            selection.subscribe(($selection) => {
                let newSelected = $selection.hasAnyChildren(new ListFileItem(this.fileId));
                if (this.selected || newSelected) {
                    this.selected = newSelected;
                    this.update();
                }
                if (newSelected) {
                    this.moveToFront();
                }
            })
        );
        this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded));
        this.unsubscribe.push(gapHighlight.subscribe(this.updateBinded));
    }

    update() {
        const _map = get(map);
        const layerEventManager = map.layerEventManager;
        let file = get(this.file)?.file;
        if (!_map || !layerEventManager || !file) {
            return;
        }

        if (
            file._data.style &&
            file._data.style.color &&
            this.layerColor !== `#${file._data.style.color}`
        ) {
            replaceColor(this.fileId, this.layerColor, `#${file._data.style.color}`);
            this.layerColor = `#${file._data.style.color}`;
        }

        this.loadIcons();

        try {
            let source = _map.getSource(this.fileId) as GeoJSONSource | undefined;
            if (source) {
                source.setData(this.getGeoJSON());
            } else {
                _map.addSource(this.fileId, {
                    type: 'geojson',
                    data: this.getGeoJSON(),
                });
            }

            if (!_map.getLayer(this.fileId)) {
                _map.addLayer(
                    {
                        id: this.fileId,
                        type: 'line',
                        source: this.fileId,
                        layout: {
                            'line-join': 'round',
                            'line-cap': 'round',
                        },
                        paint: {
                            'line-color': ['get', 'color'],
                            'line-width': ['get', 'width'],
                            'line-opacity': ['get', 'opacity'],
                        },
                    },
                    ANCHOR_LAYER_KEY.tracks
                );

                layerEventManager.on('click', this.fileId, this.layerOnClickBinded);
                layerEventManager.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
                layerEventManager.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
                layerEventManager.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
                layerEventManager.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
            }

            let visibleTrackSegmentIds: string[] = [];
            file.forEachSegment((segment, trackIndex, segmentIndex) => {
                if (!segment._data.hidden) {
                    visibleTrackSegmentIds.push(`${trackIndex}-${segmentIndex}`);
                }
            });
            const segmentFilter: FilterSpecification = [
                'in',
                ['get', 'trackSegmentId'],
                ['literal', visibleTrackSegmentIds],
            ];

            _map.setFilter(this.fileId, segmentFilter, { validate: false });

            if (get(gapHighlight)) {
                let gapsSource = _map.getSource(this.fileId + '-gaps') as GeoJSONSource | undefined;
                let gapsData = this.getGapsGeoJSON();
                if (gapsSource) {
                    gapsSource.setData(gapsData);
                } else {
                    _map.addSource(this.fileId + '-gaps', {
                        type: 'geojson',
                        data: gapsData,
                    });
                }

                if (!_map.getLayer(this.fileId + '-gaps')) {
                    _map.addLayer(
                        {
                            id: this.fileId + '-gaps',
                            type: 'line',
                            source: this.fileId + '-gaps',
                            layout: {
                                'line-join': 'round',
                                'line-cap': 'round',
                            },
                            paint: {
                                'line-color': ['get', 'color'],
                                'line-width': ['get', 'width'],
                            },
                        },
                        ANCHOR_LAYER_KEY.gapHighlight
                    );
                }

                _map.setFilter(this.fileId + '-gaps', segmentFilter, { validate: false });
            } else {
                if (_map.getLayer(this.fileId + '-gaps')) {
                    _map.removeLayer(this.fileId + '-gaps');
                }
                if (_map.getSource(this.fileId + '-gaps')) {
                    _map.removeSource(this.fileId + '-gaps');
                }
            }

            if (get(directionMarkers)) {
                if (!_map.getLayer(this.fileId + '-direction')) {
                    _map.addLayer(
                        {
                            id: this.fileId + '-direction',
                            type: 'symbol',
                            source: this.fileId,
                            layout: {
                                'text-field': '»',
                                'text-offset': [0, -0.1],
                                'text-keep-upright': false,
                                'text-max-angle': 361,
                                'text-allow-overlap': true,
                                'text-font': ['Noto Sans Bold'],
                                'symbol-placement': 'line',
                                'symbol-spacing': 20,
                            },
                            paint: {
                                'text-color': 'white',
                                'text-halo-width': 0.2,
                                'text-halo-color': 'white',
                            },
                        },
                        ANCHOR_LAYER_KEY.directionMarkers
                    );
                }

                _map.setFilter(this.fileId + '-direction', segmentFilter, { validate: false });
            } else {
                if (_map.getLayer(this.fileId + '-direction')) {
                    _map.removeLayer(this.fileId + '-direction');
                }
            }

            let waypointSource = _map.getSource(this.fileId + '-waypoints') as
                | GeoJSONSource
                | undefined;
            this.currentWaypointData = this.getWaypointsGeoJSON();
            if (waypointSource) {
                waypointSource.setData(this.currentWaypointData);
            } else {
                _map.addSource(this.fileId + '-waypoints', {
                    type: 'geojson',
                    data: this.currentWaypointData,
                    promoteId: 'waypointIndex',
                });
            }

            if (!_map.getLayer(this.fileId + '-waypoints')) {
                _map.addLayer(
                    {
                        id: this.fileId + '-waypoints',
                        type: 'symbol',
                        source: this.fileId + '-waypoints',
                        layout: {
                            'icon-image': ['get', 'icon'],
                            'icon-size': 0.3,
                            'icon-anchor': 'bottom',
                            'icon-padding': 0,
                            'icon-allow-overlap': true,
                        },
                    },
                    ANCHOR_LAYER_KEY.waypoints
                );

                layerEventManager.on(
                    'mouseenter',
                    this.fileId + '-waypoints',
                    this.waypointLayerOnMouseEnterBinded
                );
                layerEventManager.on(
                    'mouseleave',
                    this.fileId + '-waypoints',
                    this.waypointLayerOnMouseLeaveBinded
                );
                layerEventManager.on(
                    'click',
                    this.fileId + '-waypoints',
                    this.waypointLayerOnClickBinded
                );
                layerEventManager.on(
                    'mousedown',
                    this.fileId + '-waypoints',
                    this.waypointLayerOnMouseDownBinded
                );
                layerEventManager.on(
                    'touchstart',
                    this.fileId + '-waypoints',
                    this.waypointLayerOnTouchStartBinded
                );
            }

            let visibleWaypoints: number[] = [];
            file.wpt.forEach((waypoint, waypointIndex) => {
                if (!waypoint._data.hidden) {
                    visibleWaypoints.push(waypointIndex);
                }
            });

            _map.setFilter(
                this.fileId + '-waypoints',
                ['in', ['get', 'waypointIndex'], ['literal', visibleWaypoints]],
                { validate: false }
            );
        } catch (e) {
            // No reliable way to check if the map is ready to add sources and layers
            return;
        }
    }

    remove() {
        const _map = get(map);

        if (_map) {
            _map.off('style.load', this.updateBinded);
        }

        const layerEventManager = map.layerEventManager;
        if (layerEventManager) {
            layerEventManager.off('click', this.fileId, this.layerOnClickBinded);
            layerEventManager.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
            layerEventManager.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
            layerEventManager.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
            layerEventManager.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);

            layerEventManager.off(
                'mouseenter',
                this.fileId + '-waypoints',
                this.waypointLayerOnMouseEnterBinded
            );
            layerEventManager.off(
                'mouseleave',
                this.fileId + '-waypoints',
                this.waypointLayerOnMouseLeaveBinded
            );
            layerEventManager.off(
                'click',
                this.fileId + '-waypoints',
                this.waypointLayerOnClickBinded
            );
            layerEventManager.off(
                'mousedown',
                this.fileId + '-waypoints',
                this.waypointLayerOnMouseDownBinded
            );
            layerEventManager.off(
                'touchstart',
                this.fileId + '-waypoints',
                this.waypointLayerOnTouchStartBinded
            );
        }

        if (_map) {
            if (_map.getLayer(this.fileId + '-direction')) {
                _map.removeLayer(this.fileId + '-direction');
            }
            if (_map.getLayer(this.fileId)) {
                _map.removeLayer(this.fileId);
            }
            if (_map.getSource(this.fileId)) {
                _map.removeSource(this.fileId);
            }
            if (_map.getLayer(this.fileId + '-gaps')) {
                _map.removeLayer(this.fileId + '-gaps');
            }
            if (_map.getSource(this.fileId + '-gaps')) {
                _map.removeSource(this.fileId + '-gaps');
            }
            if (_map.getLayer(this.fileId + '-waypoints')) {
                _map.removeLayer(this.fileId + '-waypoints');
            }
            if (_map.getSource(this.fileId + '-waypoints')) {
                _map.removeSource(this.fileId + '-waypoints');
            }
        }

        this.unsubscribe.forEach((unsubscribe) => unsubscribe());

        removeColor(this.fileId, this.layerColor);
    }

    moveToFront() {
        const _map = get(map);
        if (!_map) {
            return;
        }
        if (_map.getLayer(this.fileId)) {
            _map.moveLayer(this.fileId, ANCHOR_LAYER_KEY.tracks);
        }
        if (_map.getLayer(this.fileId + '-gaps')) {
            _map.moveLayer(this.fileId + '-gaps', ANCHOR_LAYER_KEY.gapHighlight);
        }
        if (_map.getLayer(this.fileId + '-waypoints')) {
            _map.moveLayer(this.fileId + '-waypoints', ANCHOR_LAYER_KEY.waypoints);
        }
        if (_map.getLayer(this.fileId + '-direction')) {
            _map.moveLayer(this.fileId + '-direction', ANCHOR_LAYER_KEY.directionMarkers);
        }
    }

    layerOnMouseEnter(e: any) {
        let trackIndex = e.features[0].properties.trackIndex;
        let segmentIndex = e.features[0].properties.segmentIndex;

        if (
            get(currentTool) === Tool.SCISSORS &&
            get(selection).hasAnyParent(
                new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
            )
        ) {
            mapCursor.notify(MapCursorState.SCISSORS, true);
        } else {
            mapCursor.notify(MapCursorState.LAYER_HOVER, true);
        }
    }

    layerOnMouseLeave() {
        mapCursor.notify(MapCursorState.SCISSORS, false);
        mapCursor.notify(MapCursorState.LAYER_HOVER, false);
    }

    layerOnMouseMove(e: any) {
        if (e.originalEvent.shiftKey) {
            let trackIndex = e.features[0].properties.trackIndex;
            let segmentIndex = e.features[0].properties.segmentIndex;

            const file = get(this.file)?.file;
            if (file) {
                const closest = getClosestLinePoint(
                    file.trk[trackIndex].trkseg[segmentIndex].trkpt,
                    { lat: e.lngLat.lat, lon: e.lngLat.lng }
                );
                trackpointPopup?.setItem({ item: closest, fileId: this.fileId });
            }
        }
    }

    layerOnClick(e: MapLayerMouseEvent) {
        if (
            get(currentTool) === Tool.ROUTING &&
            get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])
        ) {
            return;
        }

        let trackIndex = e.features![0].properties!.trackIndex;
        let segmentIndex = e.features![0].properties!.segmentIndex;

        if (
            get(currentTool) === Tool.SCISSORS &&
            get(selection).hasAnyParent(
                new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
            )
        ) {
            if (get(map)?.queryRenderedFeatures(e.point, { layers: ['split-controls'] }).length) {
                // Clicked on split control, ignoring
                return;
            }

            fileActions.split(get(splitAs), this.fileId, trackIndex, segmentIndex, {
                lat: e.lngLat.lat,
                lon: e.lngLat.lng,
            });
            return;
        }

        let file = get(this.file)?.file;
        if (!file) {
            return;
        }

        let item = undefined;
        if (get(treeFileView) && file.getSegments().length > 1) {
            // Select inner item
            item =
                file.children[trackIndex].children.length > 1
                    ? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
                    : new ListTrackItem(this.fileId, trackIndex);
        } else {
            item = new ListFileItem(this.fileId);
        }

        if (e.originalEvent.ctrlKey || e.originalEvent.metaKey) {
            selection.addSelectItem(item);
        } else {
            selection.selectItem(item);
        }
    }

    layerOnContextMenu(e: any) {
        if (e.originalEvent.ctrlKey) {
            this.layerOnClick(e);
        }
    }

    waypointLayerOnMouseEnter(e: MapLayerMouseEvent) {
        if (this.draggedWaypointIndex !== null) {
            return;
        }
        let file = get(this.file)?.file;
        if (!file) {
            return;
        }

        let waypointIndex = e.features![0].properties!.waypointIndex;
        let waypoint = file.wpt[waypointIndex];
        waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });

        mapCursor.notify(MapCursorState.WAYPOINT_HOVER, true);
    }

    waypointLayerOnMouseLeave() {
        mapCursor.notify(MapCursorState.WAYPOINT_HOVER, false);
    }

    waypointLayerOnClick(e: MapLayerMouseEvent) {
        e.preventDefault();

        let waypointIndex = e.features![0].properties!.waypointIndex;
        let file = get(this.file)?.file;
        if (!file) {
            return;
        }

        let waypoint = file.wpt[waypointIndex];
        if (get(currentTool) === Tool.WAYPOINT) {
            if (this.selected) {
                if (e.originalEvent.shiftKey) {
                    fileActions.deleteWaypoint(this.fileId, waypointIndex);
                } else {
                    selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
                    selectedWaypoint.set([waypoint, this.fileId]);
                }
            } else {
                if (get(treeFileView)) {
                    selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
                } else {
                    selection.selectItem(new ListFileItem(this.fileId));
                }
                selectedWaypoint.set([waypoint, this.fileId]);
            }
        } else {
            if (get(treeFileView)) {
                if ((e.originalEvent.ctrlKey || e.originalEvent.metaKey) && this.selected) {
                    selection.addSelectItem(new ListWaypointItem(this.fileId, waypointIndex));
                } else {
                    selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
                }
            } else {
                if (!this.selected) {
                    selection.selectItem(new ListFileItem(this.fileId));
                }
                waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
            }
        }
    }

    waypointLayerOnMouseDown(e: MapLayerMouseEvent) {
        if (get(currentTool) !== Tool.WAYPOINT || !this.selected) {
            return;
        }
        const _map = get(map);
        if (!_map) {
            return;
        }

        e.preventDefault();
        _map.dragPan.disable();

        this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
        this.draggingStartingPosition = e.point;
        waypointPopup?.hide();

        _map.on('mousemove', this.waypointLayerOnMouseMoveBinded);
        _map.once('mouseup', this.waypointLayerOnMouseUpBinded);
    }

    waypointLayerOnTouchStart(e: MapLayerTouchEvent) {
        if (e.points.length !== 1 || get(currentTool) !== Tool.WAYPOINT || !this.selected) {
            return;
        }
        const _map = get(map);
        if (!_map) {
            return;
        }

        this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
        this.draggingStartingPosition = e.point;
        waypointPopup?.hide();

        e.preventDefault();
        _map.dragPan.disable();

        _map.on('touchmove', this.waypointLayerOnMouseMoveBinded);
        _map.once('touchend', this.waypointLayerOnMouseUpBinded);
    }

    waypointLayerOnMouseMove(e: MapLayerMouseEvent | MapLayerTouchEvent) {
        if (this.draggedWaypointIndex === null || e.point.equals(this.draggingStartingPosition)) {
            return;
        }

        mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, true);

        (
            this.currentWaypointData!.features[this.draggedWaypointIndex].geometry as GeoJSON.Point
        ).coordinates = [e.lngLat.lng, e.lngLat.lat];

        let waypointSource = get(map)?.getSource(this.fileId + '-waypoints') as
            | GeoJSONSource
            | undefined;
        if (waypointSource) {
            waypointSource.updateData({
                update: [
                    {
                        id: this.draggedWaypointIndex,
                        newGeometry: {
                            type: 'Point',
                            coordinates: [e.lngLat.lng, e.lngLat.lat],
                        },
                    },
                ],
            });
        }
    }

    waypointLayerOnMouseUp(e: MapLayerMouseEvent | MapLayerTouchEvent) {
        mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false);

        const _map = get(map);
        if (!_map) {
            return;
        }

        _map.dragPan.enable();

        _map.off('mousemove', this.waypointLayerOnMouseMoveBinded);
        _map.off('touchmove', this.waypointLayerOnMouseMoveBinded);

        if (this.draggedWaypointIndex === null) {
            return;
        }
        if (e.point.equals(this.draggingStartingPosition)) {
            this.draggedWaypointIndex = null;
            return;
        }

        getElevation([
            {
                lat: e.lngLat.lat,
                lon: e.lngLat.lng,
            },
        ]).then((ele) => {
            if (this.draggedWaypointIndex === null) {
                return;
            }
            fileActionManager.applyToFile(this.fileId, (file) => {
                let wpt = file.wpt[this.draggedWaypointIndex!];
                wpt.setCoordinates({
                    lat: e.lngLat.lat,
                    lon: e.lngLat.lng,
                });
                wpt.ele = ele[0];
            });
            this.draggedWaypointIndex = null;
        });
    }

    getGeoJSON(): GeoJSON.FeatureCollection {
        let file = get(this.file)?.file;
        if (!file) {
            return {
                type: 'FeatureCollection',
                features: [],
            };
        }

        let data = file.toGeoJSON();

        let trackIndex = 0,
            segmentIndex = 0;
        for (let feature of data.features) {
            if (!feature.properties) {
                feature.properties = {};
            }
            if (!feature.properties.color) {
                feature.properties.color = this.layerColor;
            }
            if (!feature.properties.opacity) {
                feature.properties.opacity = get(defaultOpacity);
            }
            if (!feature.properties.width) {
                feature.properties.width = get(defaultWidth);
            }
            if (
                get(selection).hasAnyParent(
                    new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
                ) ||
                get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), true)
            ) {
                feature.properties.width = feature.properties.width + 2;
                feature.properties.opacity = Math.min(1, feature.properties.opacity + 0.1);
            }
            feature.properties.trackIndex = trackIndex;
            feature.properties.segmentIndex = segmentIndex;
            feature.properties.trackSegmentId = `${trackIndex}-${segmentIndex}`;

            segmentIndex++;
            if (segmentIndex >= file.trk[trackIndex].trkseg.length) {
                segmentIndex = 0;
                trackIndex++;
            }
        }
        return data;
    }

    getGapsGeoJSON(): GeoJSON.FeatureCollection {
        let file = get(this.file)?.file;

        let features: GeoJSON.Feature[] = [];

        if (!file) {
            return {
                type: 'FeatureCollection',
                features,
            };
        }

        let width = get(defaultWidth) + 2;
        let color = invertColor(this.layerColor);

        file.forEachSegment((segment, trackIndex, segmentIndex) => {
            let recentDistances: number[] = [];

            for (let i = 1; i < segment.trkpt.length; i++) {
                let previous = segment.trkpt[i - 1];
                let current = segment.trkpt[i];
                let d = distance(previous, current);

                let isGap = false;
                if (recentDistances.length >= GAP_MIN_BASELINE_SAMPLES) {
                    let baseline = median(recentDistances);
                    isGap =
                        d > GAP_MIN_ABSOLUTE_DISTANCE && d > baseline * GAP_RELATIVE_MULTIPLIER;
                }

                if (isGap) {
                    features.push({
                        type: 'Feature',
                        geometry: {
                            type: 'LineString',
                            coordinates: [
                                [previous.getLongitude(), previous.getLatitude()],
                                [current.getLongitude(), current.getLatitude()],
                            ],
                        },
                        properties: {
                            width,
                            color,
                            trackIndex,
                            segmentIndex,
                            trackSegmentId: `${trackIndex}-${segmentIndex}`,
                        },
                    });
                }

                // Always feed the baseline window, even for flagged distances: the median
                // is robust to the occasional outlier, and this prevents the baseline from
                // ever getting permanently stuck (e.g. after a stationary GPS warm-up).
                recentDistances.push(d);
                if (recentDistances.length > GAP_BASELINE_WINDOW) {
                    recentDistances.shift();
                }
            }
        });

        return {
            type: 'FeatureCollection',
            features,
        };
    }

    getWaypointsGeoJSON(): GeoJSON.FeatureCollection {
        let file = get(this.file)?.file;

        let data: GeoJSON.FeatureCollection = {
            type: 'FeatureCollection',
            features: [],
        };

        if (!file) {
            return data;
        }

        file.wpt.forEach((waypoint, index) => {
            data.features.push({
                type: 'Feature',
                geometry: {
                    type: 'Point',
                    coordinates: [waypoint.getLongitude(), waypoint.getLatitude()],
                },
                properties: {
                    fileId: this.fileId,
                    waypointIndex: index,
                    icon: `waypoint-${getSymbolKey(waypoint.sym) ?? 'default'}-${this.layerColor}`,
                },
            });
        });

        return data;
    }

    loadIcons() {
        const _map = get(map);
        let file = get(this.file)?.file;
        if (!_map || !file) {
            return;
        }

        let symbols = new Set<string | undefined>();
        file.wpt.forEach((waypoint) => {
            symbols.add(getSymbolKey(waypoint.sym));
        });

        symbols.forEach((symbol) => {
            const iconId = `waypoint-${symbol ?? 'default'}-${this.layerColor}`;
            loadSVGIcon(_map, iconId, getSvgForSymbol(symbol, this.layerColor));
        });
    }
}
