UNPKG

transitive-js

Version:

A tool for generating dynamic stylized transit maps that are easy to understand.

426 lines (366 loc) 12.6 kB
import { forEach } from 'lodash' import measureText from 'measure-text' import d3 from 'd3' // TODO: replace w/ other quadtree library import { getFontSizeWithUnit } from '../util' import SegmentLabel from './segmentlabel' /** * Labeler object */ export default class Labeler { constructor(transitive) { this.transitive = transitive this.clear() } clear() { this.points = [] } updateLabelList(graph) { this.points = [] forEach(graph.vertices, (vertex) => { const point = vertex.point if ( point.getType() === 'PLACE' || point.getType() === 'MULTI' || (point.getType() === 'STOP' && point.isSegmentEndPoint) ) { this.points.push(point) } }) this.points.sort((a, b) => { if (a.containsFromPoint() || a.containsToPoint()) return -1 if (b.containsFromPoint() || b.containsToPoint()) return 1 return 0 }) } updateQuadtree() { this.quadtree = d3.geom.quadtree().extent([ [-this.width, -this.height], [this.width * 2, this.height * 2] ])([]) this.addPointsToQuadtree() // this.addSegmentsToQuadtree(); } addPointsToQuadtree() { forEach(this.points, (point) => { const mbbox = point.getMarkerBBox() if (mbbox) this.addBBoxToQuadtree(point.getMarkerBBox()) }) } addSegmentsToQuadtree() { forEach(this.transitive.renderSegments, (segment) => { if (segment.getType() !== 'TRANSIT') return let lw = this.transitive.style.compute( this.transitive.style.segments['stroke-width'], this.transitive.display, segment ) lw = parseFloat(lw.substring(0, lw.length - 2), 10) - 2 let x, x1, x2, y, y1, y2 // debug(segment.toString()); if (segment.renderData.length === 2) { // basic straight segment if (segment.renderData[0].x === segment.renderData[1].x) { // vertical x = segment.renderData[0].x - lw / 2 y1 = segment.renderData[0].y y2 = segment.renderData[1].y this.addBBoxToQuadtree({ height: Math.abs(y1 - y2), width: lw, x: x, y: Math.min(y1, y2) }) } else if (segment.renderData[0].y === segment.renderData[1].y) { // horizontal x1 = segment.renderData[0].x x2 = segment.renderData[1].x y = segment.renderData[0].y - lw / 2 this.addBBoxToQuadtree({ height: lw, width: Math.abs(x1 - x2), x: Math.min(x1, x2), y: y }) } } if (segment.renderData.length === 4) { // basic curved segment if (segment.renderData[0].x === segment.renderData[1].x) { // vertical first x = segment.renderData[0].x - lw / 2 y1 = segment.renderData[0].y y2 = segment.renderData[3].y this.addBBoxToQuadtree({ height: Math.abs(y1 - y2), width: lw, x: x, y: Math.min(y1, y2) }) x1 = segment.renderData[0].x x2 = segment.renderData[3].x y = segment.renderData[3].y - lw / 2 this.addBBoxToQuadtree({ height: lw, width: Math.abs(x1 - x2), x: Math.min(x1, x2), y: y }) } else if (segment.renderData[0].y === segment.renderData[1].y) { // horiz first x1 = segment.renderData[0].x x2 = segment.renderData[3].x y = segment.renderData[0].y - lw / 2 this.addBBoxToQuadtree({ height: lw, width: Math.abs(x1 - x2), x: Math.min(x1, x2), y: y }) x = segment.renderData[3].x - lw / 2 y1 = segment.renderData[0].y y2 = segment.renderData[3].y this.addBBoxToQuadtree({ height: Math.abs(y1 - y2), width: lw, x: x, y: Math.min(y1, y2) }) } } }) } addBBoxToQuadtree(bbox) { if ( bbox.x + bbox.width / 2 < 0 || bbox.x - bbox.width / 2 > this.width || bbox.y + bbox.height / 2 < 0 || bbox.y - bbox.height / 2 > this.height ) { return } this.quadtree.add([bbox.x + bbox.width / 2, bbox.y + bbox.height / 2, bbox]) this.maxBBoxWidth = Math.max(this.maxBBoxWidth, bbox.width) this.maxBBoxHeight = Math.max(this.maxBBoxHeight, bbox.height) } doLayout() { this.width = this.transitive.display.width this.height = this.transitive.display.height this.maxBBoxWidth = 0 this.maxBBoxHeight = 0 this.updateQuadtree() return { pointLabels: this.placePointLabels(), segmentLabels: this.placeSegmentLabels() } } /** placePointLabels **/ placePointLabels() { const styler = this.transitive.styler const placedLabels = [] // var labeledPoints = [] forEach(this.points, (point) => { const labelText = point.label.getText() if (!labelText) return point.label.fontFamily = styler.compute2('labels', 'font-family', point) point.label.fontSize = styler.compute2('labels', 'font-size', point) const textDimensions = measureText({ fontFamily: point.label.fontFamily || 'sans-serif', fontSize: point.label.fontSize, lineHeight: 1.2, text: labelText }) point.label.textWidth = textDimensions.width.value point.label.textHeight = textDimensions.height.value const orientations = styler.compute( styler.labels.orientations, this.transitive.display, { point: point } ) let placedLabel = false for (let i = 0; i < orientations.length; i++) { point.label.setOrientation(orientations[i]) if (!point.focused) continue if (!point.label.labelAnchor) continue const lx = point.label.labelAnchor.x const ly = point.label.labelAnchor.y // do not place label if out of range if (lx <= 0 || ly <= 0 || lx >= this.width || ly > this.height) continue const labelBBox = point.label.getBBox() const overlaps = this.findOverlaps(point.label, labelBBox) // do not place label if it overlaps with others if (overlaps.length > 0) continue // if we reach this point, the label is good to place point.label.setVisibility(true) // labeledPoints.push(point) placedLabels.push(point.label) this.quadtree.add([ labelBBox.x + labelBBox.width / 2, labelBBox.y + labelBBox.height / 2, point.label ]) this.maxBBoxWidth = Math.max(this.maxBBoxWidth, labelBBox.width) this.maxBBoxHeight = Math.max(this.maxBBoxHeight, labelBBox.height) placedLabel = true break // do not consider any other orientations after places } // end of orientation loop // if label not placed at all, hide the element if (!placedLabel) { point.label.setVisibility(false) } }) return placedLabels } /** placeSegmentLabels **/ placeSegmentLabels() { this.placedLabelKeys = [] const placedLabels = [] // collect the bus RenderSegments const busRSegments = [] forEach(this.transitive.network.paths, (path) => { forEach(path.getRenderedSegments(), (rSegment) => { if (rSegment.type === 'TRANSIT' && rSegment.mode === 3) { busRSegments.push(rSegment) } }) }) // Generate labels for GTFS route/mode types listed in labeledModes if provided. // If labeled modes is not provided, only label the buses (mode '3'). let edgeGroups = [] const busModes = [3] const labeledModes = this.transitive.options.labeledModes || busModes forEach(this.transitive.network.paths, (path) => { forEach(path.segments, (segment) => { if ( segment.type === 'TRANSIT' && labeledModes.includes(segment.getMode()) ) { edgeGroups = edgeGroups.concat(segment.getLabelEdgeGroups()) } }) }) // iterate through the sequence collection, labeling as necessary forEach(edgeGroups, (edgeGroup) => { this.currentGroup = edgeGroup // get the array of label strings to be places (typically the unique // route short names) this.labelTextArray = edgeGroup.getLabelTextArray() // create the initial label for placement this.labelTextIndex = 0 let label = this.getNextLabel() if (!label) return // Iterate through potential anchor locations, attempting placement at // each one const labelAnchors = edgeGroup.getLabelAnchors( this.transitive.display, label.textHeight * 1.5 ) for (let i = 0; i < labelAnchors.length; i++) { label.labelAnchor = labelAnchors[i] const { x, y } = label.labelAnchor // do not consider this anchor if it is out of the display range if (!this.transitive.display.isInRange(x, y)) continue // check for conflicts with existing placed elements const conflicts = this.findOverlaps(label, label.getBBox()) if (conflicts.length === 0) { // If no overlaps/conflicts encountered, place the current label. placedLabels.push(label) // Track new label in quadtree. this.quadtree.add([x, y, label]) label = this.getNextLabel() if (!label) break } } // end of anchor iteration loop }) // end of sequence iteration loop return placedLabels } getNextLabel() { while (this.labelTextIndex < this.labelTextArray.length) { const labelText = this.labelTextArray[this.labelTextIndex] const key = this.currentGroup.edgeIds + '_' + labelText if (this.placedLabelKeys.indexOf(key) !== -1) { this.labelTextIndex++ continue } const label = this.constructSegmentLabel( this.currentGroup.renderedSegment, labelText ) this.placedLabelKeys.push(key) this.labelTextIndex++ return label } return null } constructSegmentLabel(segment, labelText) { const label = new SegmentLabel(segment, labelText) const styler = this.transitive.styler label.fontFamily = styler.compute2('segment_labels', 'font-family', segment) label.fontSize = styler.compute2('segment_labels', 'font-size', segment) const textDimensions = measureText({ fontFamily: label.fontFamily || 'sans-serif', // Append 'px' if a unit was not specified in font-size. fontSize: getFontSizeWithUnit(label.fontSize), lineHeight: 1.2, text: labelText }) label.textWidth = textDimensions.width.value label.textHeight = textDimensions.height.value label.computeContainerDimensions() return label } findOverlaps(label, labelBBox) { // Get bounding box to check. const minX = labelBBox.x - this.maxBBoxWidth / 2 const minY = labelBBox.y - this.maxBBoxHeight / 2 const maxX = labelBBox.x + labelBBox.width + this.maxBBoxWidth / 2 const maxY = labelBBox.y + labelBBox.height + this.maxBBoxHeight / 2 // debug('findOverlaps %s,%s %s,%s', minX,minY,maxX,maxY); const matchItems = [] // Check quadtree for potential collisions. this.quadtree.visit((node, x1, y1, x2, y2) => { const { point } = node if (point) { const [pX, pY, pLabel] = point if ( pX >= minX && pX < maxX && pY >= minY && pY < maxY && label.intersects(pLabel) ) { matchItems.push(pLabel) } } // No need to visit children of this node if bbox falls entirely within // this node. return x1 > maxX || y1 > maxY || x2 < minX || y2 < minY }) return matchItems } findNearbySegmentLabels(label, x, y, buffer) { const minX = x - buffer const minY = y - buffer const maxX = x + buffer const maxY = y + buffer // debug('findNearby %s,%s %s,%s', minX,minY,maxX,maxY); const matchItems = [] this.quadtree.visit((node, x1, y1, x2, y2) => { const p = node.point if ( p && p[0] >= minX && p[0] < maxX && p[1] >= minY && p[1] < maxY && p[2].parent && label.parent.patternIds === p[2].parent.patternIds ) { matchItems.push(p[2]) } return x1 > maxX || y1 > maxY || x2 < minX || y2 < minY }) return matchItems } }