fixes leaflet-elevation in build

This commit is contained in:
Christian Beutel
2024-03-16 19:26:26 +01:00
parent 3dd963c6f4
commit eae455112b
48 changed files with 941 additions and 136 deletions

View File

@@ -0,0 +1,783 @@
import * as D3 from './d3.js';
const _ = L.Control.Elevation.Utils;
export var Chart = L.Control.Elevation.Chart = L.Class.extend({
includes: L.Evented ? L.Evented.prototype : L.Mixin.Events,
initialize(opts, control) {
this.options = opts;
this.control = control;
this._data = control._data || [];
// cache registered components
this._props = {
scales : {},
paths : {},
areas : {},
grids : {},
axes : {},
legendItems : {},
tooltipItems: {},
};
this._scales = {};
this._domains = {};
this._ranges = {};
this._paths = {};
this._brushEnabled = opts.dragging;
this._zoomEnabled = opts.zooming;
opts.xTicks = this._xTicks();
opts.yTicks = this._yTicks();
let chart = this._chart = D3.Chart(opts);
// SVG Container
this._container = chart.svg;
// Panes
this._grid = chart.pane('grid');
this._area = chart.pane('area');
this._point = chart.pane('point');
this._axis = chart.pane('axis');
this._legend = chart.pane('legend');
this._tooltip = chart.pane('tooltip');
this._ruler = chart.pane('ruler');
// Scales
this._initScale();
// Helpers
this._mask = chart.get('mask');
this._context = chart.get('context');
this._brush = chart.get('brush');
this._zoom = d3.zoom();
this._drag = d3.drag();
// Interactions
this._initInteractions();
// svg.on('resize', (e)=>console.log(e.detail));
// Handle multi-track segments
this._maskGaps = [];
control.on('eletrack_added', ({index}) => {
this._maskGaps.push(index);
control.once('elepoint_added', ({index}) => this._maskGaps.push(index));
});
},
update(props) {
if (props) {
if (props.data) this._data = props.data;
if (props.options) this.options = props.options;
}
this.options.xTicks = this._xTicks();
this.options.yTicks = this._yTicks();
this._updateScale();
this._updateAxis();
this._updateMargins();
this._updateLegend();
this._updateClipper();
this._updateArea();
return this;
},
render() {
return container => container.append(() => this._container.node());
},
clear() {
this._resetDrag();
this._hideDiagramIndicator()
this._area.selectAll('path').attr("d", "M0 0");
this._context.clearRect(0, 0, this._width(), this._height());
this._mask.selectAll(".gap").remove();
this._maskGaps = [];
// if (this._path) {
// this._x.domain([0, 1]);
// this._y.domain([0, 1]);
// }
},
_drawPath(name) {
let path = this._paths[name];
let area = this._props.areas[name];
path.datum(this._data).attr("d",
D3.Area(
L.extend({}, area, {
width : this._width(),
height : this._height(),
scaleX : this._scales[area.scaleX],
scaleY : this._scales[area.scaleY]
})
)
);
if (!path.classed('leaflet-hidden')) {
this.options.preferCanvas
? _.drawCanvas(this._context, path)
: _.append(this._area.node(), path.node());
}
},
_hasActiveLayers() {
const paths = this._paths;
for (var i in paths) {
if (!paths[i].classed('leaflet-hidden')) {
return true;
}
}
return false;
},
/**
* Initialize "d3-brush".
*/
_initBrush(e) {
const brush = ({selection}) => {
if (selection && this._data.length) {
let start = this._findIndexForXCoord(selection[0]);
let end = this._findIndexForXCoord(selection[1]);
this.fire('dragged', { dragstart: this._data[start], dragend: this._data[end] });
}
}
const focus = (e) => {
if (this._data.length && (e.type != 'brush' /*|| e.sourceEvent*/)) {
let rect = this._chart.panes.brush.select('.overlay').node();
let coords = d3.pointers(e, rect)[0];
let xCoord = coords[0];
let item = this._data[this._findIndexForXCoord(xCoord)];
this.fire("mouse_move", { item: item, xCoord: xCoord });
}
};
this._brush
.filter(({shiftKey, button}) => !shiftKey && !button && this._brushEnabled)
.on("end.update", brush)
.on("brush.update", focus);
this._chart.panes.brush
.on("mouseenter.focus touchstart.focus", this.fire.bind(this, "mouse_enter"))
.on("mouseout.focus touchend.focus", this.fire.bind(this, "mouse_out") )
.on("mousemove.focus touchmove.focus", focus );
},
/**
* Initialize "d3-zoom"
*/
_initClipper() {
let svg = this._container;
let margin = this.options.margins;
const zoom = this._zoom;
const onStart = ({transform, sourceEvent}) => {
if (sourceEvent && sourceEvent.type == "mousedown") svg.style('cursor', 'grabbing');
if (transform.k == 1 && transform.x == 0) {
this._container.classed('zoomed', true);
// Apply d3-zoom and bind <mask>
if (this._mask) {
this._point.attr('mask', 'url(#' + this._mask.attr('id') + ')');
}
}
this.zooming = true;
};
const onEnd = ({transform}) => {
if (transform.k ==1 && transform.x == 0) {
this._container.classed('zoomed', false);
// Reset d3-zoom and remove <mask>
if (this._mask) {
this._point.attr('mask', null);
}
}
this.zooming = false;
svg.style('cursor', '');
};
const onZoom = ({transform, sourceEvent}) => {
// TODO: find a faster way to redraw the chart.
this.zooming = false;
this._updateScale(); // hacky way for restoring x scale when zooming out
this.zooming = true;
this._scales.distance = this._x = transform.rescaleX(this._x); // calculate x scale at zoom level
if (this._scales.time) this._scales.time = transform.rescaleX(this._scales.time); // calculate x scale at zoom level
this._resetDrag();
if (sourceEvent && sourceEvent.type == "mousemove") {
this._hideDiagramIndicator();
}
this.fire('zoom');
};
zoom
.scaleExtent([1, 10])
.extent([
[margin.left, 0],
[this._width() - margin.right, this._height()]
])
.translateExtent([
[margin.left, -Infinity],
[this._width() - margin.right, Infinity]
])
.filter(({shiftKey, buttons}) => (shiftKey || buttons == 4) && this._zoomEnabled)
.on("start", onStart)
.on("end", onEnd)
.on("zoom", onZoom);
svg.call(zoom); // add zoom functionality to "svg" group
// d3.select("body").on("keydown.grabzoom keyup.grabzoom", (e) => svg.style('cursor', e.shiftKey ? 'move' : ''));
},
_initInteractions() {
this._initBrush();
this._initRuler();
this._initClipper();
this._initLegend();
},
/**
* Toggle chart data on legend click
*/
_initLegend() {
this._container.on('legend_clicked', ({detail}) => {
let { path, legend, name, enabled } = detail;
if (path) {
let label = _.select('text', legend);
let rect = _.select('rect', legend);
_.toggleStyle(label, 'text-decoration-line', 'line-through', enabled);
_.toggleStyle(rect, 'fill-opacity', '0', enabled);
_.toggleClass(path, 'leaflet-hidden', enabled);
this._updateArea();
this.fire("elepath_toggle", { path, name, legend, enabled })
}
});
},
/**
* Initialize "ruler".
*/
_initRuler() {
if (!this.options.ruler) return;
// const yMax = this._height();
const formatNum = d3.format(".0f");
const drag = this._drag;
const label = (e, d) => {
let yMax = this._height();
let y = this._ruler.data()[0].y;
if (y >= yMax || y <= 0) this._ruler.select(".horizontal-drag-label").text('');
this._hideDiagramIndicator();
};
const position = (e, d) => {
let yCoord = d3.pointers(e, this._area.node())[0][1];
let yMax = this._height();
let y = _.clamp(yCoord, [0, yMax]);
this._ruler
.data([L.extend(this._ruler.data()[0], { y: y })])
.attr("transform", d => "translate(" + d.x + "," + d.y + ")")
.classed('active', y < yMax);
this._container
.select(".horizontal-drag-label")
.text(formatNum(this._y.invert(y)) + " " + (this.options.imperial ? 'ft' : 'm'));
this.fire('ruler_filter', { coords: yCoord < yMax && yCoord > 0 ? this._findCoordsForY(yCoord) : [] });
}
drag
.on("start end", label)
.on("drag", position);
this._ruler.call(drag);
},
/**
* Initialize x and y scales
*/
_initScale() {
let opts = this.options;
this._registerAxisScale({
axis : 'x',
position: 'bottom',
attr : opts.xAttr,
min : opts.xAxisMin,
max : opts.xAxisMax,
name : 'distance'
});
this._registerAxisScale({
axis : 'y',
position : 'left',
attr : opts.yAttr,
min : opts.yAxisMin,
max : opts.yAxisMax,
name : 'altitude'
});
this._x = this._scales.distance;
this._y = this._scales.altitude;
},
_registerAreaPath(props) {
if (props.scale == 'y') props.scale = this._y;
else if (props.scale == 'x') props.scale = this._x;
let opts = this.options;
if (!props.xAttr) props.xAttr = opts.xAttr;
if (!props.yAttr) props.yAttr = opts.yAttr;
if (typeof props.preferCanvas === "undefined") props.preferCanvas = opts.preferCanvas;
// Save paths in memory for latter usage
this._paths[props.name] = D3.Path(props);
this._props.areas[props.name] = props;
if (opts.legend) {
this._props.legendItems[props.name] = {
name : props.name,
label : props.label,
color : props.color,
className: props.className,
path : this._paths[props.name]
};
}
},
_registerAxisGrid(props) {
if (props.scale == 'y') props.scale = this._y;
else if (props.scale == 'x') props.scale = this._x;
this._props.grids[props.name || props.axis] = props;
},
_registerAxisScale(props) {
if (props.scale == 'y') props.scale = this._y;
else if (props.scale == 'x') props.scale = this._x;
let opts = this.options;
let scale = props.scale;
if (typeof this._scales[props.name] === 'function') {
props.scale = this._scales[props.name]; // retrieve cached scale
} else if (typeof scale !== 'function') {
scale = L.extend({
data : this._data,
forceBounds: opts.forceAxisBounds
}, scale);
scale.attr = scale.attr || props.name;
let domain = this._domains[props.name] = D3.Domain(props);
let range = this._ranges[props.name] = D3.Range(props);
scale.range = scale.range || range(this._width(),this._height());
scale.domain = scale.domain || domain(this._data);
this._props.scales[props.name] = scale;
props.scale = this._scales[props.name] = D3.Scale(scale);
}
if (!props.ticks) {
if (props.axis == 'x') props.ticks = this._xTicks.bind(this);
else if (props.axis == 'y') props.ticks = this._yTicks.bind(this);
}
this._props.axes[props.name] = props;
if (props.name == this.options.yScale) this._y = scale;
if (props.name == this.options.xScale) this._x = scale;
return scale;
},
/**
* Add a point of interest over the chart
*/
_registerCheckPoint(point) {
if (!this._data.length) return;
const {xAttr, yAttr} = this.options;
let item, x, y;
if (point.latlng) {
item = this._data[this._findIndexForLatLng(point.latlng)];
x = this._x(item[xAttr]);
y = this._y(item[yAttr]);
} else if (!isNaN(point[xAttr])) {
x = this._x(point[xAttr]);
item = this._data[this._findIndexForXCoord(x)]
y = this._y(item[yAttr]);
}
this._point.call(D3.CheckPoint({
point: point,
width: this._width(),
height: this._height(),
x: x,
y: y,
}));
},
_registerTooltip(props) {
props.order = props.order ?? 1000;
this._props.tooltipItems[props.name] = props;
},
_updateArea() {
// Reset and update chart profiles
this._context.clearRect(0, 0, this._width(), this._height());
_.each(this._paths, (path, i) => !path.classed('leaflet-hidden') && this._drawPath(i));
},
_updateAxis() {
let opts = this.options;
const gridOpts = {
width : this._width(),
height : this._height(),
tickFormat: "",
};
const axesOpts = {
width : this._width(),
height : this._height(),
};
// Reset grids
this._grid.selectAll('g').remove();
_.each(this._props.grids, (grid, i) => {
if (opts[i] !== false && opts[i] !== 'summary') {
this._grid.call(D3.Grid(L.extend({}, gridOpts, grid)))
}
});
// Rest axis
this._axis.selectAll('g').remove();
_.each(this._props.axes, (axis, i) => {
if (opts[i] !== false && opts[i] !== 'summary') {
this._axis.call(D3.Axis(L.extend({}, axesOpts, axis)));
}
});
// Adjust axis scale positions
this._axis
.selectAll('.y.axis.right')
.each((d, i, n) => {
let axis = d3.select(n[i]);
let transform = axis.attr('transform');
let translate = transform.substring(transform.indexOf("(") + 1, transform.indexOf(")")).split(",");
axis.attr('transform', 'translate(' + (+translate[0] + (i * 40)) + ',' + translate[1] + ')')
if (i > 0) {
axis.select(':scope > path') .attr('opacity', 0.25);
axis.selectAll(':scope > .tick line').attr('opacity', 0.75);
}
});
},
_updateClipper() {
const { xAttr, margins } = this.options;
const data = this._data;
this._zoom
.scaleExtent([1, 10])
.extent([
[margins.left, 0],
[this._width() - margins.right, this._height()]
])
.translateExtent([
[margins.left, -Infinity],
[this._width() - margins.right, Infinity]
]);
// Apply svg mask on multi-track segments
this._mask.selectAll(".gap").remove()
this._maskGaps.forEach((d, i) => {
if (i >= this._maskGaps.length - 2) return;
let x1 = this._x(data[this._findIndexForLatLng(data[this._maskGaps[i]].latlng)][xAttr]);
let x2 = this._x(data[this._findIndexForLatLng(data[this._maskGaps[i + 1]].latlng)][xAttr]);
this._mask
.append("rect")
.attr("x", x1)
.attr("y", 0)
.attr("width", x2 - x1 )
.attr("height", this._height())
.attr('class', 'gap')
.attr('fill-opacity', '0.8')
.attr("fill", 'black'); // hide = black (mask)
});
},
_updateLegend: function () {
let xAxesB = this._axis.selectAll('.x.axis.bottom').nodes().length;
// Get legend items
let items = Object.keys(this._paths);
// Calculate legend item positions
let n = items.length;
let v = Array(Math.floor(n / 2)).fill(null).map((d, i) => (i + 1) * 2 - (1 - Math.sign(n % 2)));
let rev = v.slice().reverse().map((d) => -(d));
// push a fake element to handle center alignment (odd numbers)
if (n % 2 !== 0) {
rev.push(0);
}
v = rev.concat(v);
// Reset legend items
this._legend.selectAll('g').remove();
// Render legend items
_.each(this._props.legendItems, (legend) => {
this._legend.append("g").call(
D3.LegendItem(L.extend({
width : this._width(),
height : this._height(),
margins: this.options.margins,
}, legend))
);
});
// Render legend item switcher
if (n > 1) {
this._legend.append("g").call(
D3.LegendSmall({
width : this._width(),
height : this._height(),
items : items,
onClick: (selected) => {
_.each(items, name => this._togglePath(name, selected == name, true));
this._updateArea();
}
})
);
}
_.each(items, (name, i) => {
// Adjust legend item positions
this._legend.select('[data-name=' + name + ']').attr("transform", "translate(" + (v[i] * 55) + ", " + (xAxesB * 2) + ")");
// Set initial state (disabled controls)
this._togglePath(name, !(name in this.options && this.options[name] == 'disabled'), true);
});
},
_updateMargins() {
// Get chart margins
let xAxesB = this._axis.selectAll('.x.axis.bottom').nodes().length;
let xAxesL = this._axis.selectAll('.y.axis.left').nodes().length;
let xAxesR = this._axis.selectAll('.y.axis.right').nodes().length;
let marginB = 60 + (xAxesB * 2);
let marginL = 10 + (xAxesL * 30);
let marginR = 40 + (xAxesR * 30);
let marginsUpdated = false
// Adjust right margin
if (xAxesR && this.options.margins.right < marginR) {
this.options.margins.right = marginR;
marginsUpdated = true;
}
// Adjust left margin
if (xAxesL && this.options.margins.left < marginL) {
this.options.margins.left = marginL;
marginsUpdated = true;
}
// Adjust bottom margin
if (xAxesB && this.options.margins.bottom < marginB) {
this.options.margins.bottom = marginB;
marginsUpdated = true;
}
if (marginsUpdated) {
this.fire('margins_updated');
}
},
_updateScale() {
if (this.zooming) return { x: this._x, y: this._y };
for (let i in this._scales) {
this._scales[i]
.domain(this._domains[i](this._data))
.range(this._ranges[i](this._width(), this._height()))
}
return { x: this._x, y: this._y };
},
/**
* Calculates chart width.
*/
_width() {
if (this._chart) this._chart._width;
const { width, margins } = this.options;
return width - margins.left - margins.right;
},
/**
* Calculates chart height.
*/
_height() {
if (this._chart) return this._chart._height;
const { height, margins } = this.options;
return height - margins.top - margins.bottom;
},
/*
* Finds data entries above a given y-elevation value and returns geo-coordinates
*/
_findCoordsForY(y) {
let data = this._data;
let z = this._y.invert(y);
// save indexes of elevation values above the horizontal line
const list = data.reduce((array, item, index) => {
if (item[this.options.yAttr] >= z) array.push(index);
return array;
}, []);
let start = 0;
let next;
// split index list into blocks of coordinates
const coords = list.reduce((array, _, curr) => {
next = curr + 1;
if (list[next] !== list[curr] + 1 || next === list.length) {
array.push(
list
.slice(start, next)
.map(i => data[i].latlng)
);
start = next;
}
return array;
}, []);
return coords;
},
/*
* Finds a data entry for a given x-coordinate of the diagram
*/
_findIndexForXCoord(x) {
return d3
.bisector(d => d[this.options.xAttr])
.left(this._data || [0, 1], this._x.invert(x));
},
/*
* Finds a data entry for a given latlng of the map
*/
_findIndexForLatLng(latlng) {
let result = null;
let d = Infinity;
this._data.forEach((item, index) => {
let dist = latlng.distanceTo(item.latlng);
if (dist < d) {
d = dist;
result = index;
}
});
return result;
},
/*
* Removes the drag rectangle and zoms back to the total extent of the data.
*/
_resetDrag() {
if (this._chart.panes.brush.select(".selection").attr('width')) {
this._chart.panes.brush.call(this._brush.clear);
this._hideDiagramIndicator();
this.fire('reset_drag');
}
},
_resetZoom() {
if (this._zoom) {
this._zoom.transform(this._chart.svg, d3.zoomIdentity);
}
},
/**
* Display distance and altitude level ("focus-rect").
*/
_showDiagramIndicator(item, xCoordinate) {
this._tooltip
.attr("display", null)
.call(D3.Tooltip({
xCoord: xCoordinate,
yCoord: this._y(item[this.options.yAttr]),
height: this._height(),
width : this._width(),
labels: this._props.tooltipItems,
item: item
}));
},
_togglePath(name, enabled = true, lazy = false) {
let path = this._paths[name];
let legend = this._container.select('.legend [data-name=' + name + ']');
path.classed('leaflet-hidden', !enabled);
legend.select('text').style('text-decoration-line', enabled ? '' : 'line-through');
legend.select('rect').style('fill-opacity', enabled ? '': '0');
// Apply d3-zoom (bind <clipPath> mask)
if (this._mask) {
path.attr('mask', 'url(#' + this._mask.attr('id') + ')');
}
if (!lazy) {
this._updateArea();
}
},
_hideDiagramIndicator() {
this._tooltip.attr("display", 'none');
},
/**
* Calculate chart xTicks
*/
_xTicks() {
if (this.__xTicks) this.__xTicks = this.options.xTicks;
return this.__xTicks || Math.round(this._width() / 75);
},
/**
* Calculate chart yTicks
*/
_yTicks() {
if (this.__yTicks) this.__yTicks = this.options.yTicks;
return this.__yTicks || Math.round(this._height() / 30);
}
});

View File

@@ -0,0 +1,649 @@
export const Area = ({
width,
height,
xAttr,
yAttr,
scaleX,
scaleY,
interpolation = "curveLinear"
}) => {
return d3.area()
.curve(typeof interpolation === 'string' ? d3[interpolation] : interpolation)
.x(d => (d.xDiagCoord = scaleX(d[xAttr])))
.y0(height)
.y1(d => scaleY(d[yAttr]));
};
export const Path = ({
name,
color,
strokeColor,
strokeOpacity,
fillOpacity,
className = ''
}) => {
let path = d3.create('svg:path')
if (name) path.classed(name + ' ' + className, true);
path.style("pointer-events", "none");
path
.attr("fill", color || '#3366CC')
.attr("stroke", strokeColor || '#000')
.attr("stroke-opacity", strokeOpacity || '1')
.attr("fill-opacity", fillOpacity || '0.8');
return path;
};
export const Axis = ({
type = "axis",
tickSize = 6,
tickPadding = 3,
position,
height,
width,
axis,
scale,
ticks,
tickFormat,
label,
labelX,
labelY,
name = "",
onAxisMount,
}) => {
return g => {
let [w, h] = [0, 0];
if (position == "bottom") h = height;
if (position == "right") w = width;
if (axis == "x" && type == "grid") {
tickSize = -height;
} else if (axis == "y" && type == "grid") {
tickSize = -width;
}
let axisScale = d3["axis" + position.replace(/\b\w/g, l => l.toUpperCase())]()
.scale(scale)
.ticks(typeof ticks === 'function' ? ticks() : ticks)
.tickPadding(tickPadding)
.tickSize(tickSize)
.tickFormat(tickFormat);
let axisGroup = g.append("g")
.attr("class", [axis, type, position, name].join(" "))
.attr("transform", "translate(" + w + "," + h + ")")
.call(axisScale);
if (label) {
axisGroup.append("svg:text")
.attr("x", labelX)
.attr("y", labelY)
.text(label);
}
if (onAxisMount) {
axisGroup.call(onAxisMount);
}
return axisGroup;
};
};
export const Grid = (props) => {
props.type = "grid";
return Axis(props);
};
export const PositionMarker = ({
theme,
xCoord = 0,
yCoord = 0,
labels = {},
item = {},
length = 0,
}) => {
return g => {
g.attr("class", "height-focus-group");
let line = g.select('.height-focus.line');
let circle = g.select('.height-focus.circle-lower');
let text = g.select('.height-focus-label');
if (!line.node()) line = g.append('svg:line');
if (!circle.node()) circle = g.append('svg:circle');
if (!text.node()) text = g.append('svg:text');
if (isNaN(xCoord) || isNaN(yCoord)) return g;
circle
.attr("class", theme + " height-focus circle-lower")
.attr("transform", "translate(" + xCoord + "," + yCoord + ")")
.attr("r", 6)
.attr("cx", 0)
.attr("cy", 0);
line
.attr("class", theme + " height-focus line")
.attr("x1", xCoord)
.attr("x2", xCoord)
.attr("y1", yCoord)
.attr("y2", length);
text
.attr("class", theme + " height-focus-label")
.style("pointer-events", "none")
.attr("x", xCoord + 5)
.attr("y", length);
let label;
Object
.keys(labels)
.sort((a, b) => labels[a].order - labels[b].order) // TODO: any performance issues?
.forEach((i)=> {
label = text.select(".height-focus-" + labels[i].name);
if (!label.size()) {
label = text.append("svg:tspan")
.attr("class", "height-focus-" + labels[i].name /*+ " " + "order-" + labels[i].order*/)
.attr("dy", "1.5em");
}
label.text(typeof labels[i].value !== "function" ? labels[i].value : labels[i].value(item));
});
text.select('tspan').attr("dy", text.selectAll('tspan').size() > 1 ? "-1.5em" : "0em" );
text.selectAll('tspan').attr("x", xCoord + 5);
return g;
}
};
export const LegendItem = ({
name,
label,
width,
height,
margins = {},
color,
path,
className = ''
}) => {
return g => {
g
.attr("class", "legend-item legend-" + name.toLowerCase())
.attr("data-name", name);
g.on('click.legend', () => d3.select(g.node().ownerSVGElement || g)
.dispatch("legend_clicked", {
detail: {
path: path.node(),
name: name,
legend: g.node(),
enabled: !path.classed('leaflet-hidden'),
}
})
);
g.append("svg:rect")
.attr("x", (width / 2) - 50)
.attr("y", height + margins.bottom / 2)
.attr("width", 50)
.attr("height", 10)
.attr("fill", color)
.attr("stroke", "#000")
.attr("stroke-opacity", "0.5")
.attr("fill-opacity", "0.25")
.attr("class", className);
g.append('svg:text')
.text(L._(label || name))
.attr("x", (width / 2) + 5)
.attr("font-size", 10)
.style("font-weight", "700")
.attr('y', height + margins.bottom / 2)
.attr('dy', "0.75em");
return g;
}
};
export const LegendSmall = ({
width,
height,
items,
onClick
}) => {
return g => {
let idx = 0;
g.data([{
x: width,
y: height + 40
}]).attr("transform", d => "translate(" + d.x + "," + d.y + ")").classed('legend-switcher');
// let label = g.selectAll(".legend-switcher-label") .data([{ idx: 0 }]);
let label = g.append('svg:text')
.attr("class", "legend-switcher-label")
.attr("text-anchor", "end")
.attr("x", -25)
.attr("y", 5)
.on("mousedown", (e, d) => setIdx(L.Util.wrapNum((idx + 1), [0, items.length])));
let symbol = g.selectAll(".legend-switcher-symbol").data([
{ type: d3.symbolTriangle, x: 0, y: 3, angle: 0, size: 50, id: "down" },
{ type: d3.symbolTriangle, x: -13, y: 0, angle: 180, size: 50, id: "up" }
]);
symbol.exit().remove();
label.exit().remove();
symbol.enter()
.append("svg:path")
.attr("class", "legend-switcher-symbol")
.attr("cursor", 'pointer')
.attr("fill", "#000")
.merge(symbol)
.attr("d",
d3.symbol()
.type(d => d.type)
.size(d => d.size)
)
.attr("transform", d => "translate(" + d.x + "," + d.y + ") rotate(" + d.angle + ")")
.on("mousedown", (e, d) => setIdx(L.Util.wrapNum((d.id === "up" ? idx + 1 : idx - 1), [0, items.length])));
const setIdx = (id) => {
idx = id;
// label.enter()
// .append('text')
// .attr("class", "legend-switcher-label")
// .attr("text-anchor", "end")
// .attr("x", -25)
// .attr("y", 5)
// .merge(label.data([{ idx: id }]))
// .text(d => items.length ? (items[idx][0].toUpperCase() + items[idx].slice(1)) : '')
// .on("mousedown", (e, d) => setIdx(L.Util.wrapNum((idx + 1), [0, items.length])));
label.text(items.length ? L._((items[idx][0].toUpperCase() + items[idx].slice(1))) : '');
onClick(items[idx]);
};
setIdx(0);
return g;
};
};
export const Tooltip = ({
xCoord,
yCoord,
width,
height,
labels = {},
item = {},
}) => {
return g => {
let line = g.select('.mouse-focus-line');
let box = g.select('.mouse-focus-label');
if (!line.node()) line = g.append('svg:line');
if (!box.node()) box = g.append("g");
let rect = box.select(".mouse-focus-label-rect");
let text = box.select(".mouse-focus-label-text");
if (!rect.node()) rect = box.append("svg:rect");
if (!text.node()) text = box.append("svg:text");
// Sets focus-label-text position to the left / right of the mouse-focus-line
let xAlign = 0;
let yAlign = 0;
let bbox = { width: 0, height: 0 };
try { bbox = text.node().getBBox(); } catch (e) { return g; }
if (xCoord) xAlign = xCoord + (xCoord < width / 2 ? 10 : -bbox.width - 10);
if (yCoord) yAlign = Math.max(yCoord - bbox.height, L.Browser.webkit ? 0 : -Infinity);
line
.attr('class', 'mouse-focus-line')
.attr('x2', xCoord)
.attr('y2', 0)
.attr('x1', xCoord)
.attr('y1', height);
box
.attr('class', 'mouse-focus-label');
rect
.attr("class", "mouse-focus-label-rect")
.attr("x", xAlign - 5)
.attr("y", yAlign - 5)
.attr("width", bbox.width + 10)
.attr("height", bbox.height + 10)
.attr("rx", 3)
.attr("ry", 3);
text
.attr("class", "mouse-focus-label-text")
.style("font-weight", "700")
.attr("y", yAlign);
let label;
Object
.keys(labels)
.sort((a, b) => labels[a].order - labels[b].order) // TODO: any performance issues?
.forEach((i)=> {
label = text.select(".mouse-focus-label-" + labels[i].name);
if (!label.size()) {
label = text.append("svg:tspan", ".mouse-focus-label-x")
.attr("class", "mouse-focus-label-" + labels[i].name /*+ " " + "order-" + labels[i].order*/)
.attr("dy", "1.5em");
}
label.text(typeof labels[i].value !== "function" ? labels[i].value : labels[i].value(item));
});
text.select('tspan').attr("dy", "1em");
text.selectAll('tspan').attr("x", xAlign);
return g;
};
};
export const Ruler = ({
height,
width,
}) => {
return g => {
g.data([{
x: 0,
y: height,
}])
.attr("transform", d => "translate(" + d.x + "," + d.y + ")");
let rect = g.selectAll('.horizontal-drag-rect').data([{ w: width }]);
let line = g.selectAll('.horizontal-drag-line').data([{ w: width }]);
let label = g.selectAll('.horizontal-drag-label').data([{ w: width - 8 }]);
let symbol = g.selectAll('.horizontal-drag-symbol')
.data([{
type: d3.symbolTriangle,
x: width + 7,
y: 0,
angle: -90,
size: 50
}]);
rect.exit().remove();
line.exit().remove();
label.exit().remove();
symbol.exit().remove();
rect.enter()
.append("svg:rect")
.attr("class", "horizontal-drag-rect")
.attr("x", 0)
.attr("y", -8)
.attr("height", 8)
.attr('fill', 'none')
.attr('pointer-events', 'all')
.merge(rect)
.attr("width", d => d.w);
line.enter()
.append("svg:line")
.attr("class", "horizontal-drag-line")
.attr("x1", 0)
.merge(line)
.attr("x2", d => d.w);
label.enter()
.append("svg:text")
.attr("class", "horizontal-drag-label")
.attr("text-anchor", "end")
.attr("y", -8)
.merge(label)
.attr("x", d => d.w)
symbol
.enter()
.append("svg:path")
.attr("class", "horizontal-drag-symbol")
.merge(symbol)
.attr("d",
d3.symbol()
.type(d => d.type)
.size(d => d.size)
)
.attr("transform", d => "translate(" + d.x + "," + d.y + ") rotate(" + d.angle + ")");
return g;
}
};
export const CheckPoint = ({
point,
height,
width,
x,
y
}) => {
return g => {
if (isNaN(x) || isNaN(y)) return g;
if (!point.item || !point.item.property('isConnected')) {
point.position = point.position || "bottom";
point.item = g.append('g');
point.item.append("svg:line")
.attr("y1", 0)
.attr("x1", 0)
.attr("style","stroke: rgb(51, 51, 51); stroke-width: 0.5; stroke-dasharray: 2, 2;");
point.item
.append("svg:circle")
.attr("class", " height-focus circle-lower")
.attr("r", 3);
if (point.label) {
point.item.append("svg:text")
.attr("dx", "4px")
.attr("dy", "-4px");
}
}
point.item
.datum({
pos: point.position,
x: x,
y: y
})
.attr("class", d => "point " + d.pos)
.attr("transform", d => "translate(" + d.x + "," + d.y + ")");
point.item.select('line')
.datum({
y2: ({'top': -y, 'bottom': height - y})[point.position],
x2: ({'left': -x, 'right': width - x})[point.position] || 0
})
.attr("y2", d => d.y2)
.attr("x2", d => d.x2)
if (point.label) {
point.item.select('text')
.text(point.label);
}
return g;
}
};
export const Domain = ({
min,
max,
attr,
name,
forceBounds,
scale
}) => function(data) {
attr = (scale && scale.attr) || attr || name;
let domain = data && data.length ? d3.extent(data, d => d[attr]) : [0, 1];
if (typeof min !== "undefined" && (min < domain[0] || forceBounds)) {
domain[0] = min;
}
if (typeof max !== "undefined" && (max > domain[1] || forceBounds)) {
domain[1] = max;
}
return domain;
};
export const Range = ({
axis
}) => function(width, height) {
if (axis == 'x') return [0, width];
else if (axis == 'y') return [height, 0];
};
export const Scale = ({
data,
attr,
min,
max,
forceBounds,
range,
}) => {
return d3.scaleLinear()
.range(range)
.domain(Domain({min, max, attr, forceBounds})(data));
};
export const Bisect = ({
data = [0, 1],
scale,
x,
attr
}) => {
return d3
.bisector(d => d[attr])
.left(data, scale.invert(x));
};
export const Chart = ({
width,
height,
margins = {},
ruler,
}) => {
const w = width - margins.left - margins.right;
const h = height - margins.top - margins.bottom;
// SVG Container
const svg = d3.create("svg:svg").attr("class", "background");
const defs = svg.append("svg:defs");
// SVG Groups
const g = svg.append("g");
const panes = {
grid : g.append("g").attr("class", "grid"),
area : g.append('g').attr("class", "area"),
axis : g.append('g').attr("class", "axis"),
point : g.append('g').attr("class", "point"),
brush : g.append("g").attr("class", "brush"),
tooltip: g.append("g").attr("class", "tooltip").attr('display', 'none'),
ruler : g.append('g').attr('class', 'ruler'),
legend : g.append('g').attr("class", "legend"),
};
// SVG Paths
const mask = panes.area .append("svg:mask") .attr("id", 'elevation-clipper-' + Math.random().toString(36).substr(2, 9)).attr('fill-opacity', 1);
const maskRect = mask .append("svg:rect") .attr('class', 'zoom') .attr('fill', 'white'); // white = transparent
// Canvas Paths
const foreignObject = panes.area .append('svg:foreignObject').attr('mask', 'url(#' + mask.attr('id') + ')');
const canvas = foreignObject.append('xhtml:canvas') .attr('class', 'canvas-plot');
const context = canvas.node().getContext('2d');
// Add tooltip
panes.tooltip.call(Tooltip({ xCoord: 0, yCoord: 0, height: h, width : w, labels: {} }));
// Add the brushing
let brush = d3.brushX().on('start.cursor end.cursor brush.cursor', () => panes.brush.select(".overlay").attr('cursor', null));
// Scales
const scale = (opts) => ({ x: Scale(opts.x), y: Scale(opts.y)});
let utils = {
defs,
mask,
canvas,
context,
brush,
};
let chart = {
svg,
g,
panes,
utils,
scale,
};
// Resize
chart._resize = ({
width,
height,
margins = {},
ruler,
}) => {
const w = width - margins.left - margins.right;
const h = height - margins.top - margins.bottom;
svg .attr("width", width).attr("height", height).attr("viewBox", `0 0 ${width} ${height}`);
g .attr("transform", "translate(" + margins.left + "," + margins.top + ")");
// Fix: https://github.com/Raruto/leaflet-elevation/issues/123
// Fix: https://github.com/Raruto/leaflet-elevation/issues/232
if (
/Mac|iPod|iPhone|iPad/.test(navigator.platform) &&
/AppleWebkit/i.test(navigator.userAgent) &&
!/Chrome/.test(navigator.userAgent)
) {
canvas .style("transform", "translate(" + margins.left + "px," + margins.top + "px)");
}
maskRect .attr("width", w).attr("height", h).attr("x", 0).attr("y", 0);
foreignObject.attr('width', w).attr('height', h);
canvas .attr('width', w).attr('height', h);
if (ruler) {
panes.ruler.call(Ruler({ height: h, width: w }));
}
panes.brush.call(brush.extent( [ [0,0], [w, h] ] ));
panes.brush.select(".overlay").attr('cursor', null);
chart._width = w;
chart._height = h;
chart.svg.dispatch('resize', { detail: { width: w, height: h } } );
};
chart.pane = (name) => (panes[name] || (panes[name] = g.append('g').attr("class", name)));
chart.get = (name) => utils[name];
chart._resize({ width, height, margins});
return chart;
};

View File

@@ -0,0 +1,96 @@
import * as D3 from './d3.js';
const _ = L.Control.Elevation.Utils;
export var Marker = L.Class.extend({
initialize(options, control) {
this.options = options;
this.control = control
switch(this.options.marker) {
case 'elevation-line':
// this._container = d3.create("g").attr("class", "height-focus-group");
break;
case 'position-marker':
// this._marker = L.circleMarker([0, 0], { pane: 'overlayPane', radius: 6, fillColor: '#fff', fillOpacity:1, color: '#000', weight:1, interactive: false });
this._marker = L.marker([0, 0], { icon: this.options.markerIcon, zIndexOffset: 1000000, interactive: false });
break;
}
this._labels = {};
return this;
},
addTo(map) {
this._map = map;
switch(this.options.marker) {
case 'elevation-line': this._container = d3.select(map.getPane('elevationPane')).select("svg > g").call(D3.PositionMarker({})); break;
case 'position-marker': this._marker.addTo(map, { pane: 'overlayPane' }); break;
}
return this;
},
/**
* Update position marker ("leaflet-marker").
*/
update(props) {
if (props) this._props = props;
else props = this._props;
if (!props) return;
if (props.options) this.options = props.options;
if (!this._map) this.addTo(props.map);
this._latlng = props.item.latlng;
switch(this.options.marker) {
case 'elevation-line':
if (this._container) {
let point = this._map.latLngToLayerPoint(this._latlng);
point = L.extend({}, props.item, this._map._rotate ? this._map.rotatedPointToMapPanePoint(point) : point);
let yMax = (this.control._height() / props.yCoordMax * point[this.options.yAttr]);
if (!isFinite(yMax) || isNaN(yMax)) yMax = 0;
this._container.classed("leaflet-hidden", false);
this._container.call(D3.PositionMarker({
theme : this.options.theme,
xCoord: point.x,
yCoord: point.y,
length: point.y - yMax, // normalized Y
labels: this._labels,
item: point,
}));
}
break;
case 'position-marker':
_.removeClass(this._marker.getElement(), 'leaflet-hidden');
this._marker.setLatLng(this._latlng);
break;
}
},
/*
* Hides the position/height indicator marker drawn onto the map
*/
remove() {
this._props = null;
switch(this.options.marker) {
case 'elevation-line': this._container && this._container.classed("leaflet-hidden", true); break;
case 'position-marker': _.addClass(this._marker.getElement(), 'leaflet-hidden'); break;
}
},
getLatLng() {
return this._latlng;
},
_registerTooltip(props) {
this._labels[props.name] = props;
}
});

View File

@@ -0,0 +1,43 @@
const _ = L.Control.Elevation.Utils;
export var Summary = L.Class.extend({
initialize(opts, control) {
this.options = opts;
this.control = control;
this.labels = {};
let summary = this._container = _.create("div", "elevation-summary " + (opts.summary ? opts.summary + "-summary" : ''));
_.style(summary, 'max-width', opts.width ? opts.width + 'px' : '');
},
render() {
return container => container.append(() => this._container);
},
reset() {
this._container.innerHTML = '';
},
append(className, label, value) {
this._container.innerHTML += `<span class="${className}"><span class="summarylabel">${label}</span><span class="summaryvalue">${value}</span></span>`;
return this;
},
update() {
Object
.keys(this.labels)
.sort((a, b) => this.labels[a].order - this.labels[b].order) // TODO: any performance issues?
.forEach((i)=> {
let value = typeof this.labels[i].value !== "function" ? this.labels[i].value : this.labels[i].value(this.control.track_info, this.labels[i].unit || '');
this.append(i /*+ " order-" + this.labels[i].order*/, L._(this.labels[i].label), value, this.labels[i].order);
});
},
_registerSummary(data) {
for (let i in data) {
data[i].order = data[i].order ?? 1000;
this.labels[i] = data[i];
}
}
});