Add osm-map-nano

This commit is contained in:
Corentin 2025-09-10 00:22:48 +09:00
commit bedef43e13
22 changed files with 739 additions and 0 deletions

View file

@ -0,0 +1,12 @@
'use strict';
import { Mapper } from './src/Mapper.js';
module.exports = {Mapper};
// module.exports = require('./src/index.js');
// if(process.env.NODE_ENV === 'production')
// module.exports = require('./dist/index.min.cjs');
// else
// module.exports = require('./dist/index.cjs');

View file

@ -0,0 +1,13 @@
{
"name": "osm-map-nano",
"version": "0.0.1",
"description": "",
"license": "LGPL-3.0-or-later",
"author": "",
"type": "module",
"main": "src/index.js",
"scripts": {
"lint": "eslint packages/*",
"test": "echo \"Error: no test specified\" && exit 1"
}
}

View file

@ -0,0 +1,313 @@
namespace OSM
{
export type Tags = {
[key: string]: string
};
export class Bounds {
constructor(
public max_lat: number,
public max_lon: number,
public min_lat: number,
public min_lon: number) {}
}
export class Member {
constructor(
public ref: number,
public role: string,
public type: string) {}
}
export class Node {
constructor(
public id: number,
public lat: number,
public lon: number,
public tags: Tags,
public uid: number,
public version: number,
public visible: boolean) {}
}
export class Relation {
constructor(
public id: number,
public members: Member[],
public tags: Tags,
public uid: number,
public version: number,
public visible: boolean) {}
}
export class Way {
constructor(
public id: number,
public nodes: Node[],
public tags: Tags,
public uid: number,
public version: number,
public visible: boolean) {}
}
}
interface MapperOptions {
bounds?: OSM.Bounds,
canvas?: HTMLCanvasElement
}
export class Mapper
{
current_bounds: OSM.Bounds | null;
data_bounds: OSM.Bounds | null;
nodes: Map<Number, OSM.Node> = new Map();
relations: Map<Number, OSM.Relation> = new Map();
ways: Map<Number, OSM.Way> = new Map();
canvas: HTMLCanvasElement;
zoom_level: number = 0;
zoom_scale: number = 0;
private readonly DEG2RAD: number = Math.PI / 180;
private readonly PI_4: number = Math.PI / 4;
constructor({bounds, canvas}: MapperOptions = {})
{
this.current_bounds = bounds || null;
this.data_bounds = bounds || null;
this.canvas = canvas || document.createElement('canvas');
}
private getXMLTags(element: Element): OSM.Tags
{
let tags: OSM.Tags = {};
element.querySelectorAll('tag').forEach(tag => {
tags[tag.getAttribute('k') || ''] = tag.getAttribute('v') || '';
});
return tags;
}
private getXMLVisible(element: Element): boolean
{
const visible: string = element.getAttribute('visible') || 'false';
return visible === '1' || visible.toLowerCase() === 'true';
}
public loadXMLData(xml_data: string)
{
const parser = new DOMParser();
const doc = parser.parseFromString(xml_data, 'application/xml');
const doc_bounds = doc.querySelector('bounds');
if(doc_bounds !== null)
{
this.data_bounds = new OSM.Bounds(
parseFloat(doc_bounds.getAttribute('maxlat') || '0'),
parseFloat(doc_bounds.getAttribute('maxlon') || '0'),
parseFloat(doc_bounds.getAttribute('minlat') || '0'),
parseFloat(doc_bounds.getAttribute('minlon') || '0')
);
this.current_bounds = this.data_bounds;
}
doc.querySelectorAll('node').forEach(doc_node => {
const node_id: number = parseInt(doc_node.getAttribute('id') || '0');
this.nodes.set(node_id, new OSM.Node(
node_id,
parseFloat(doc_node.getAttribute('lat') || '0'),
parseFloat(doc_node.getAttribute('lon') || '0'),
this.getXMLTags(doc_node),
parseFloat(doc_node.getAttribute('uid') || '0'),
parseFloat(doc_node.getAttribute('version') || '0'),
this.getXMLVisible(doc_node)
));
});
doc.querySelectorAll('way').forEach(doc_way => {
const way_id: number = parseInt(doc_way.getAttribute('id') || '0');
let nodes: OSM.Node[] = [];
doc_way.querySelectorAll('nd').forEach(way_nd => {
const node_id: number = parseInt(way_nd.getAttribute('ref') || '0');
const node = this.nodes.get(node_id);
if(node === undefined)
throw `Invalid data : reference to non existent Node ${node_id} from Way ${way_id}`;
nodes.push(node);
});
this.ways.set(way_id, new OSM.Way(
way_id,
nodes,
this.getXMLTags(doc_way),
parseFloat(doc_way.getAttribute('uid') || '0'),
parseFloat(doc_way.getAttribute('version') || '0'),
this.getXMLVisible(doc_way)
));
});
doc.querySelectorAll('relation').forEach(doc_relation => {
const relation_id: number = parseInt(doc_relation.getAttribute('id') || '0');
let members: OSM.Member[] = [];
doc_relation.querySelectorAll('member').forEach(member => {
members.push(new OSM.Member(
parseInt(member.getAttribute('ref') || '0'),
member.getAttribute('role') || '',
member.getAttribute('rype') || ''));
});
this.relations.set(relation_id, new OSM.Relation(
relation_id,
members,
this.getXMLTags(doc_relation),
parseFloat(doc_relation.getAttribute('uid') || '0'),
parseFloat(doc_relation.getAttribute('version') || '0'),
this.getXMLVisible(doc_relation)
));
});
}
private get_map_raw_coords(lon: number, lat: number): [number, number]
{
return [
this.zoom_scale * lon * this.DEG2RAD,
this.zoom_scale * Math.log(Math.tan(this.PI_4 + (lat * this.DEG2RAD / 2)))
];
}
public draw(target_width: number, canvas?: HTMLCanvasElement)
{
if(canvas)
this.canvas = canvas;
if(this.current_bounds === null || this.data_bounds === null)
return;
this.zoom_level = Math.ceil(-Math.log2(
((this.current_bounds.max_lon - this.current_bounds.min_lon) * 2 * Math.PI) / target_width));
this.zoom_scale = (2**this.zoom_level) * target_width / (2 * Math.PI);
const [min_x, min_y] = this.get_map_raw_coords(this.current_bounds.min_lon, this.current_bounds.min_lat);
const [max_x, max_y] = this.get_map_raw_coords(this.current_bounds.max_lon, this.current_bounds.max_lat);
const width = max_x - min_x;
const height = max_y - min_y;
this.canvas.width = width;
this.canvas.height = height;
let ctx = this.canvas.getContext('2d', {alpha: false}) as CanvasRenderingContext2D;
const get_map_coords: (lon: number, lat:number) => [number, number] = (lon: number, lat: number) => {
const [x, y] = this.get_map_raw_coords(lon, lat);
return [Math.floor(x - min_x), Math.floor(max_y - y)];
};
const background_color = 'rgb(235, 235, 235)';
ctx.fillStyle = background_color;
ctx.fillRect(0, 0, width, height);
const draw_way_polygon = (way: OSM.Way, color: string, outline: string | null = null,
width: number = 1) => {
let path_len: number = 0;
let previous_coords: [number, number] | null = null;
let path = new Path2D();
for(const node of way.nodes)
{
const coords = get_map_coords(node.lon, node.lat);
if(previous_coords === null || previous_coords[0] !== coords[0] || previous_coords[1] !== coords[1])
{
if(previous_coords === null)
path.moveTo(coords[0], coords[1]);
else
path.lineTo(coords[0], coords[1]);
path_len += 1;
previous_coords = coords;
}
}
path.closePath();
if(path_len > 2)
{
ctx.fillStyle = color;
ctx.fill(path);
if(outline !== null)
{
ctx.lineWidth = width;
ctx.stroke(path);
}
}
};
const draw_way_line = (way: OSM.Way, width: number, color: string) => {
let path_len: number = 0;
let previous_coords: [number, number] | null = null;
let path = new Path2D();
for(const node of way.nodes)
{
const coords = get_map_coords(node.lon, node.lat);
if(previous_coords === null || previous_coords[0] !== coords[0] || previous_coords[1] !== coords[1])
{
if(previous_coords === null)
path.moveTo(coords[0], coords[1]);
else
path.lineTo(coords[0], coords[1]);
path_len += 1;
previous_coords = coords;
}
}
if(path_len > 1)
{
ctx.lineWidth = width;
ctx.strokeStyle = color;
ctx.lineJoin = 'round';
ctx.stroke(path);
}
};
const bridge_outline: string = 'rgb(80, 80, 80)';
const thickest_stroke: number = Math.floor(Math.min(width, height) / 32);
this.ways.forEach(way => {
if(way.tags['building'] !== undefined)
{
draw_way_polygon(way, 'rgb(190, 180, 160)', 'rgb(120, 120, 120)');
}
});
this.ways.forEach(way => {
if(way.tags['highway'] !== undefined)
{
switch(way.tags['highway'])
{
case 'motorway':
case 'motorway_link':
draw_way_line(way, thickest_stroke, 'rgb(40, 20, 10)');
draw_way_line(way, thickest_stroke * 0.9, 'rgb(220, 160, 160)');
break;
case 'trunk':
case 'trunk_link':
draw_way_line(way, Math.floor(thickest_stroke * 0.9),
(way.tags['bridge'] !== undefined) ? bridge_outline : 'rgb(180, 120, 50)');
draw_way_line(way, Math.floor(thickest_stroke * 0.8),'rgb(240, 170, 150)');
break;
case 'primary':
case 'primary_link':
draw_way_line(way, Math.floor(thickest_stroke * 0.8),
(way.tags['bridge'] !== undefined) ? bridge_outline : 'rgb(150, 80, 60)');
draw_way_line(way, Math.floor(thickest_stroke * 0.7),'rgb(245, 215, 150)');
break;
case 'secondary':
case 'secondary_link':
draw_way_line(way, Math.floor(thickest_stroke * 0.6),
(way.tags['bridge'] !== undefined) ? bridge_outline : 'rgb(50, 40, 20)');
draw_way_line(way, Math.floor(thickest_stroke * 0.5),'rgb(252, 250, 200)');
break;
case 'tertiary':
case 'tertiary_link':
draw_way_line(way, Math.floor(thickest_stroke * 0.4),
(way.tags['bridge'] !== undefined) ? bridge_outline : 'rgb(160, 160, 160)');
draw_way_line(way, Math.floor(thickest_stroke * 0.3),'rgb(245, 245, 245)');
break;
case 'residential':
case 'unclassified':
case 'road':
draw_way_line(way, Math.floor(thickest_stroke * 0.35),
(way.tags['bridge'] !== undefined) ? bridge_outline : 'rgb(160, 160, 160)');
draw_way_line(way, Math.floor(thickest_stroke * 0.25),'rgb(250, 250, 250)');
break;
}
}
});
}
}

View file

@ -0,0 +1,5 @@
import { Mapper } from './Mapper.js';
export {
Mapper
};

View file

@ -0,0 +1,14 @@
import { Mapper } from 'osm-map-nano';
(globalThis as any).test = (canvas: HTMLCanvasElement, data: string) => {
const test = new Mapper({canvas: canvas});
test.loadXMLData(data);
console.log(JSON.stringify(test.data_bounds));
console.log(`Nodes: ${test.nodes.size}`);
console.log(`Ways: ${test.ways.size}`);
console.log(`Relations: ${test.relations.size}`);
test.draw(1600);
console.log('Draw done');
};

View file

@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

File diff suppressed because one or more lines are too long