geospatiale/classes/maptalks_CurvedLabel.js

if (!global.Geospatiale)
	global.Geospatiale = {};

/**
 * Curved text Geometry for Maptalks (UIMarker-based).
 * 
 * @param {Array.<number[]>|maptalks.Coordinate[]}
 * @param {Object} [arg1_options]
 *  @param {number} [arg1_options.base_font_size=16]
 *  @param {string} [arg1_options.class] - Overrides manual .style using a preset class.
 *  @param {number} [arg1_options.letter_spacing=0]
 *  @param {maptalks.Map} [arg1_options.map]
 *  @param {Object} [arg1_options.style]
 * 
 * @type {Geospatiale.maptalks_CurvedLabel}
 */
Geospatiale.maptalks_CurvedLabel = class {
	constructor (arg0_coords, arg1_options) {
		//Convert from parameters
		let coords = arg0_coords;
		let options = (arg1_options) ? arg1_options : {};
		
		//Declare local instance variables
		this.coords = coords;
		this.options = options;
		this.map = options.map;
		
		this.base_font_size = Math.returnSafeNumber(this.options.base_font_size, 16);
		this.base_zoom = (this.options.base_zoom !== undefined) ? this.options.base_zoom : this.map.getZoom();
		this.glyph_markers = [];
		this.style = {
			fontFamily: "sans-serif",
			opacity: 0.85,
			pointerEvents: "none",
			...this.options.style,
			fontSize: this.base_font_size
		};
		this.text_string = this.options.text_string;
		
		this.canvas = document.createElement("canvas");
		this.ctx = this.canvas.getContext("2d");
		
		//Event handling and initial render
		this.onviewchange_handler = this.render.bind(this);
		this.map.on("zoomend zooming moving moveend rotate pitch", this.onviewchange_handler);
		this.render();
	}
	
	addTo (arg0_map) {
		//Convert from parameters
		let map = (arg0_map) ? arg0_map : this.map;
		
		//Iterate over all this.glyph_markers and add it to the map
		for (let i = 0; i < this.glyph_markers.length; i++)
			this.glyph_markers[i].addTo(map);
	}
	
	/**
	 * Returns smoothed points between coords for arc curves.
	 * - Method of: {@link Geospatiale.maptalks_CurvedLabel}
	 * 
	 * @param {Array.<number[]>} arg0_coords
	 * @param [arg1_samples_per_segment=25]
	 * 
	 * @returns {Array.<number[]>}
	 */
	getSmoothPoints (arg0_coords, arg1_samples_per_segment) {
		//Convert from parameters
		let coords = arg0_coords;
		let samples_per_segment = Math.returnSafeNumber(arg1_samples_per_segment, 25);
		
		if (coords.length < 2) return coords; //Internal guard clause
		
		//Declare local instance variables
		let pt_list = [];
		let smooth_points =  [];
		
		//Iterate over coords
		for (let i = 0; i < coords.length; i++) {
			let local_coord = coords[i];
			let local_point = (local_coord.x !== undefined && local_coord.y !== undefined) ?
				[local_coord.x, local_coord.y] : [local_coord[0], local_coord[1]];
			pt_list.push(local_point);
		}
		
		//Iterate over pt_list
		for (let i = 0; i < pt_list.length - 1; i++) {
			let p0 = (i > 0) ? pt_list[i - 1] : pt_list[i];
			let p1 = pt_list[i];
			let p2 = pt_list[i + 1];
			let p3 = (i < pt_list.length - 2) ? pt_list[i + 2] : p2;
			
			//Iterate over samples_per_segment for segment
			for (let t = 0; t < 1; t += 1/samples_per_segment) {
				let t2 = t*t;
				let t3 = t2*t;
				let x = 0.5*((2 * p1[0]) + (-p0[0] + p2[0])*t + (2*p0[0] - 5*p1[0] + 4*p2[0] - p3[0])*t2 + (-p0[0] + 3*p1[0] - 3*p2[0] + p3[0])*t3);
				let y = 0.5*((2 * p1[1]) + (-p0[1] + p2[1])*t + (2*p0[1] - 5*p1[1] + 4*p2[1] - p3[1])*t2 + (-p0[1] + 3*p1[1] - 3*p2[1] + p3[1])*t3);
				smooth_points.push([x, y]);
			}
		}
		smooth_points.push(pt_list[pt_list.length - 1]);
		
		//Return statement
		return smooth_points;
	}
	
	/**
	 * Measures actual text width using a `<canvas>` to assess kerning.
	 * - Method of: {@link Geospatiale.maptalks_CurvedLabel}
	 * 
	 * @param {string} arg0_text
	 * @param {number} arg1_font_size
	 * 
	 * @returns {number}
	 */
	measureTextWidth (arg0_text, arg1_font_size) {
		//Convert from parameters
		let text = (arg0_text) ? arg0_text : "";
		let font_size =  Math.returnSafeNumber(arg1_font_size, this.base_font_size);
		
		//Declare local instance variables
		this.ctx.font = `bold ${font_size + Math.returnSafeNumber(this.options.letter_spacing)}px ${this.style.fontFamily}`;
		
		let metrics = this.ctx.measureText(text);
		
		//Return statement
		return metrics.width;
	}
	
	/**
	 * Removes the maptalks_CurvedLabel geometry from the map.
	 * - Method of: {@link Geospatiale.maptalks_CurvedLabel}
	 */
	remove () {
		//Remove map view event handlers
		if (this.map && this.onviewchange_handler)
			this.map.off("zoomend zooming moving moveend rotate pitch", this.onviewchange_handler);
		
		//Iterate over all this.glyph_markers and remove them
		for (let i = 0; i < this.glyph_markers.length; i++) try {
			this.glyph_markers[i].remove();
		} catch (e) {}
		this.glyph_markers = [];
	}
	
	/**
	 * Renders the maptalks_CurvedLabel geometry to the map.
	 * - Method of: {@link Geospatiale.maptalks_CurvedLabel}
	 */
	render () {
		if (!this.map || !this.text_string) return; //Internal guard clause
		
		//Declare local instance variables
		let char_widths = [];
		let cumulative_distances = [0];
		let map_bearing = this.map.getBearing();
		let sampled_coords = this.getSmoothPoints(this.coords, 25);
		let projected_points = [];
		let text_total_width = 0;
		let total_path_length = 0;
		
		//Iterate over all sampled_coords
		for (let i = 0; i < sampled_coords.length; i++) {
			let local_coord = new maptalks.Coordinate(sampled_coords[i]);
			let local_point = this.map.coordToPoint(local_coord, this.base_zoom);
			
			projected_points.push({ coord: local_coord, x: local_point.x, y: local_point.y });
		}
		
		//Iterate over all projected_points, calculate total_path_length; cumulative_distances
		for (let i = 1; i < projected_points.length; i++) {
			let dx = projected_points[i].x - projected_points[i - 1].x;
			let dy = projected_points[i].y - projected_points[i - 1].y;
			let distance = Math.sqrt(dx*dx + dy*dy);
			
			total_path_length += distance;
			cumulative_distances.push(total_path_length);
		}
		
		//Iterate over all characters in this.text_string; calculate char_widths, text_total_width
		for (let i = 0; i < this.text_string.length; i++) {
			let local_width = this.measureTextWidth(this.text_string[i], this.base_font_size);
			
			char_widths.push(local_width);
			text_total_width += local_width;
		}
		
		//Render character constants
		let start_distance = (total_path_length - text_total_width)/2;
		if (start_distance < 0) start_distance = 0;
		
		let current_zoom = this.map.getZoom();
		let zoom_scale = Math.pow(2, current_zoom - this.base_zoom);
		
		let current_font_size = this.base_font_size*zoom_scale;
		let marker_index = 0;
		let running_distance = start_distance;
		
		let renderChar = (i, arg1_options) => {
			//Convert from parameters
			let options = (arg1_options) ? arg1_options : {};
			
			//Declare local instance variables
			let char = this.text_string[i];
			let char_width = char_widths[i];
			let target_distance = running_distance + (char_width/2);
			
			if (target_distance > total_path_length) return; //Internal guard clause for truncation
			
			let segment_index = 0;
			while (segment_index < cumulative_distances.length - 2 && cumulative_distances[segment_index + 1] < target_distance)
				segment_index++;
			
			//Declare maths variables
			let d1 = cumulative_distances[segment_index];
			let d2 = cumulative_distances[segment_index + 1];
			let p1 = projected_points[segment_index];
			let p2 = projected_points[segment_index + 1];
			let ratio = (d2 > d1) ? (target_distance - d1)/(d2 - d1) : 0;
			
			let current_x = p1.x + (p2.x - p1.x)*ratio;
			let current_y = p1.y + (p2.y - p1.y)*ratio;
			let dx = p2.x - p1.x;
			let dy = p2.y - p1.y;
			
			let angle_deg = (Math.atan2(dy, dx)*180)/Math.PI;
			let target_pt = new maptalks.Point(current_x, current_y);
			let target_coord = this.map.pointToCoord(target_pt, this.base_zoom);
			let total_rotation = angle_deg;
			if (options.invert) total_rotation += 180;
			
			//Local helper function: sample two orthogonal screen-aligned world directions and take the maximum; corresponds to the unforeshortened axis (parallel to the camera's tilt) = pure distance attenuation
			let current_zoom = this.map.getZoom();
			let scale_constant = 100;
			let screen_pt = this.map.coordToContainerPoint(target_coord);
			let world_pt = this.map.coordToPoint(target_coord, current_zoom);
			
			let bearing_rad = -(this.map.getBearing()*Math.PI)/180; //Bearing is a float in [-180, 180]; trig handles wrapping implicitly
			let cos_bearing = Math.cos(bearing_rad);
			let sin_bearing = Math.sin(bearing_rad);
			
			let scaleAlong = (offset_x, offset_y) => {
				//Counter-rotate the requested screen-space direction into world-space
				let rotated_x = (offset_x*cos_bearing) - (offset_y*sin_bearing);
				let rotated_y = (offset_x*sin_bearing) + (offset_y*cos_bearing);
				
				let world_pt_offset = new maptalks.Point(world_pt.x + rotated_x, world_pt.y + rotated_y);
				let coord_offset = this.map.pointToCoord(world_pt_offset, current_zoom);
				let screen_pt_offset = this.map.coordToContainerPoint(coord_offset);
				
				//Return statement
				return screen_pt.distanceTo(screen_pt_offset)/scale_constant;
			};
			
			let perspective_scale = Math.max(scaleAlong(scale_constant, 0), scaleAlong(0, scale_constant));
			let zoom_scale = Math.pow(2, current_zoom - this.base_zoom);
			let current_font_size = this.base_font_size*zoom_scale*perspective_scale;
			
			let dom_el = document.createElement("div");
			//This needs an inner element since transform is reserved on wrapper
			dom_el.innerHTML = `<span style = "position: absolute; transform: translate(-50%, -50%) rotateZ(${-total_rotation}deg);">${char}</span>`;
			
			if (this.options.class) {
				dom_el.setAttribute("class", this.options.class);
			} else {
				let span_el = dom_el.querySelector("span");
				
				Object.iterate(this.style, (local_key, local_value) =>
					span_el.style[local_key] = String(local_value));
				span_el.style.fontSize = `${current_font_size}px`;
			}
			
			if (marker_index < this.glyph_markers.length) {
				let existing_marker = this.glyph_markers[marker_index];
				existing_marker.setCoordinates(target_coord);
				existing_marker.setContent(dom_el);
				if (!existing_marker.isVisible()) existing_marker.show();
			} else {
				let new_marker = new maptalks.ui.UIMarker(target_coord, {
					animation: false,
					draggable: false,
					single: false,
					pitchWithMap: true,
					rotateWithMap: true,
					
					content: dom_el
				});
				this.glyph_markers.push(new_marker);
			}
			
			running_distance += char_width;
			marker_index++;
		};
		
		//Render all characters depending on map bearing
		if (map_bearing > -90 && map_bearing <= 90) {
			for (let i = 0; i < this.text_string.length; i++) renderChar(i);
		} else {
			for (let i = this.text_string.length - 1; i >= 0; i--) renderChar(i, { invert: true });
		}
		
		//Remove markers as needed
		while (this.glyph_markers.length > marker_index) {
			let removed_marker = this.glyph_markers.pop();
			removed_marker.remove();
		}
	}
	
	/**
	 * Sets coordinates for the maptalks_CurvedLabel geometry.
	 * - Method of: {@link Geospatiale.maptalks_CurvedLabel}
	 * 
	 * @param {Array.<number[]>} arg0_coords
	 */
	setCoordinates (arg0_coords) {
		//Convert from parameters
		let coords = arg0_coords;
		
		//Set coords, then render
		this.coords = coords;
		this.render();
	}
	
	/**
	 * Sets the font size for the maptalks_CurvedLabel geometry.
	 * - Method of: {@link Geospatiale.maptalks_CurvedLabel}
	 * 
	 * @param {number} arg0_size
	 */
	setFontSize (arg0_size) {
		//Convert from parameters
		let font_size = Math.returnSafeNumber(arg0_size, 16);
		
		//Refresh font_size relative to screenspace
		this.base_font_size = font_size;
		this.style.fontSize = font_size;
		this.render();
	}
	
	/**
	 * Sets the new text for the maptalks_CurvedLabel geometry.
	 * - Method of: {@link Geospatiale.maptalks_CurvedLabel}
	 * 
	 * @param {string} arg0_text
	 */
	setText (arg0_text) {
		//Convert from parameters
		let text = arg0_text;
		
		//Set text, then render
		this.text_string = text;
		this.render();
	}
	
	/**
	 * Exports class to JSON.
	 * - Method of: {@link Geospatiale.maptalks_CurvedLabel}
	 * 
	 * @returns {Object} 
	 */
	toJSON () {
		//Declare local instance variables
		let new_options_obj = {};
		
		//Iterate over all this.options and move serialisable JSON into new_options_obj
		Object.iterate(this.options, (local_key, local_value) => {
			if (local_key !== "map")
				new_options_obj[local_key] = local_value;
		});
		
		new_options_obj.base_font_size = this.base_font_size;
		new_options_obj.base_zoom = this.base_zoom;
		new_options_obj.style = { ...this.style, fontSize: this.base_font_size };
		new_options_obj.text_string = this.text_string;
		new_options_obj.type = "curved";
		
		//Return statement
		return {
			coords: this.coords,
			options: new_options_obj
		};
	}
	
	/**
	 * Internal helper function to refresh zoom.
	 * - Method of: {@link Geospatiale.maptalks_CurvedLabel}
	 */
	updateZoom () { this.render(); }
	
	/**
	 * Creates a new maptalks_CurvedLabel geometry from JSON.
	 * - Static method of: {@link Geospatiale.maptalks_CurvedLabel}
	 * 
	 * @param {maptalks.Map} arg0_map
	 * @param {Object|string} arg1_json
	 * 
	 * @returns {Geospatiale.maptalks_CurvedLabel}
	 */
	static fromJSON (arg0_map, arg1_json) {
		//Convert from parameters
		let map = arg0_map;
		let json = (typeof arg1_json === "string") ? JSON.parse(arg1_json) : arg1_json;
		
		let options_obj = {
			...(json.options || {}),
			map: map
		};
		
		//Return statement
		return new Geospatiale.maptalks_CurvedLabel(json.coords, options_obj);
	}
};