s2maps-gpu
Version:
S2 Maps GPU - An open source, high-performance, and GPU-accelerated map engine for rendering large-scale, interactive maps.
291 lines (290 loc) • 10.8 kB
JavaScript
import { zagzig } from 'open-vector-tile';
/**
* # Glyph Source
*
* A glyph source manager to request metadata, glyphs, and images
*/
export default class GlyphSource {
active = true;
extent = 0;
name;
path;
size = 0;
defaultAdvance = 0;
maxHeight = 0;
range = 0;
texturePack;
session;
glyphWaitlist = new Map();
glyphCache = new Map(); // glyphs we have built already
isIcon = false;
/**
* @param name - the name of the source
* @param path - the path to the source
* @param texturePack - the texture pack to help define where the glyphs/images are stored
* @param session - the session
*/
constructor(name, path, texturePack, session) {
this.name = name;
this.path = path;
this.texturePack = texturePack;
this.session = session;
}
/**
* Build the source data
* @param mapID - the id of the map
* @returns the metadata, yet to be parsed
*/
async build(mapID) {
const metadata = await this._fetch(`${this.path}?type=metadata`, mapID);
if (metadata === undefined) {
this.active = false;
console.error(`FAILED TO extrapolate ${this.path} metadata`);
return { name: this.name, metadata: undefined };
}
else {
return await this._buildMetadata(metadata);
}
}
/**
* Build metadata from a buffer
* @param metadata - the metadata buffer
* @returns the metadata
*/
_buildMetadata(metadata) {
const meta = new DataView(metadata);
// build the metadata
this.extent = meta.getUint16(0, true);
this.size = meta.getUint16(2, true);
this.maxHeight = meta.getUint16(4, true);
this.range = meta.getUint16(6, true);
this.defaultAdvance = meta.getUint16(8, true) / this.extent;
this.isIcon = meta.getUint32(12, true) > 0;
// return the metadata so it can be shipped to the worker threads
return { name: this.name, metadata };
}
/**
* Given a collection of unicodes, request the glyphs from the server
* @param request - array of unicodes
* @param mapID - the id of the map we are fetching data for
* @param reqID - the id of the request
* @param worker - the worker port
*/
async glyphRequest(request, // array of codes
mapID, reqID, worker) {
const { glyphCache, glyphWaitlist, name } = this;
const promiseList = [];
const requestList = [];
const waitlistPromiseMap = new Map();
for (const code of request) {
// 1) already cached in glyphCache; do nothing
if (glyphCache.has(code))
continue;
// 2) already exists in the glyphWaitlist (downloading)
if (glyphWaitlist.has(code)) {
const promise = glyphWaitlist.get(code);
waitlistPromiseMap.set(promise.id, promise);
}
else {
// 5) no one has it
requestList.push(code);
}
}
// create THIS glyphs missing glyphs request
if (requestList.length > 0) {
const promise = this.#requestGlyphs(requestList, mapID);
promise.id = genID();
promiseList.push(promise);
for (const unicode of requestList)
glyphWaitlist.set(unicode, promise);
}
// add all waitlist promises
for (const [, promise] of waitlistPromiseMap)
promiseList.push(promise);
await Promise.all(promiseList);
// convert glyphList into a Float32Array of unicode data and ship it out
const glyphMetadata = [];
for (const unicode of request) {
const glyph = glyphCache.get(unicode);
if (glyph !== undefined)
glyphMetadata.push(glyph);
}
const glyphResponseMessage = {
mapID,
type: 'glyphresponse',
reqID,
glyphMetadata,
familyName: name,
};
worker.postMessage(glyphResponseMessage);
}
/**
* Request glyphs
* @param list - array of unicodes to request their data for
* @param mapID - the id of the map
*/
async #requestGlyphs(list, mapID) {
const { isIcon, extent, glyphCache, glyphWaitlist, maxHeight, texturePack } = this;
// 1) build the ranges, max 35 glyphs per request
const requests = this.#buildRequests(list);
// 2) return the request promise, THEN: store the glyphs in cache, build the images, and ship the images to the mapID
const promises = [];
for (const { request, substitutes } of requests) {
promises.push(this._fetch(request, mapID).then((glyphsBuf) => {
if (glyphsBuf === undefined)
return;
const images = [];
const dv = new DataView(glyphsBuf);
const size = dv.byteLength - 1;
let pos = 0;
while (pos < size) {
// build glyph metadata
let code = String(dv.getUint16(pos, true));
if (!isIcon && code === '0')
code = substitutes.shift() ?? '';
const glyph = {
code,
width: dv.getUint16(pos + 2, true) / extent,
height: dv.getUint16(pos + 4, true) / extent,
texW: dv.getUint8(pos + 6),
texH: dv.getUint8(pos + 7),
texX: 0,
texY: 0,
xOffset: zagzig(dv.getUint16(pos + 8, true)) / extent,
yOffset: zagzig(dv.getUint16(pos + 10, true)) / extent,
advanceWidth: zagzig(dv.getUint16(pos + 12, true)) / extent,
};
pos += 14;
// store in texturePack
const [posX, posY] = texturePack.addGlyph(glyph.texW, maxHeight);
glyph.texX = posX;
glyph.texY = posY;
// store glyph in cache
glyphCache.set(code, glyph);
// remove from waitlist cache
glyphWaitlist.delete(code);
// grab the image
const imageSize = glyph.texW * glyph.texH * 4;
const data = new Uint8ClampedArray(glyphsBuf.slice(pos, pos + imageSize)).buffer;
images.push({ posX, posY, width: glyph.texW, height: glyph.texH, data });
pos += imageSize;
}
// send off the images
const imagesMaxHeight = images.reduce((acc, cur) => Math.max(acc, cur.posY + cur.height), 0);
const glyphImageMessage = {
mapID,
type: 'glyphimages',
images,
maxHeight: imagesMaxHeight,
};
postMessage(glyphImageMessage, images.map((i) => i.data));
}));
}
await Promise.allSettled(promises);
}
/**
* Build requests from the list of requested glyphs
* @param list - list of requested glyphs
* @returns an array of requests
*/
#buildRequests(list) {
const { path } = this;
const requests = [];
const chunks = [];
// sort the list by unicode order first substitions second
const parsedList = list
.map((code) => {
if (code.includes('.'))
return code;
else
return Number(code);
})
.sort((a, b) => {
if (typeof a === 'string')
return 1;
if (typeof b === 'string')
return -1;
return a - b;
});
// group into batches of 150
for (let i = 0; i < parsedList.length; i += 150)
chunks.push(parsedList.slice(i, i + 150));
// group unicode numbers adjacent into the same range
for (const chunk of chunks) {
// convert chunk to mergedRanges
const merged = mergeRanges(chunk);
// shape the ranges into a base36 string
const mergedBase36 = merged.map((code) => {
if (Array.isArray(code))
return `${base36(code[0])}-${base36(code[1])}`;
else if (typeof code === 'number')
return `${base36(code)}`;
else
return code;
});
// merge the ranges into a single request
const request = `${path}?type=glyph&codes=${mergedBase36.join(',')}`;
const substitutes = mergedBase36.filter((code) => typeof code === 'string' && code.includes('.'));
requests.push({ request, substitutes });
}
return requests;
}
/**
* Fetch glyph data and or metadata
* @param path - the url to fetch the data
* @param mapID - the id of the map
* @returns the raw data if found
*/
async _fetch(path, mapID) {
const headers = {};
if (this.session.hasAPIKey(mapID)) {
const Authorization = await this.session.requestSessionToken(mapID);
if (Authorization === 'failed')
return;
if (Authorization !== undefined)
headers.Authorization = Authorization;
}
const res = await fetch(path, { headers });
if (res.status !== 200 && res.status !== 206)
return;
return await res.arrayBuffer();
}
}
/**
* Merge ranges of unicodes into as few ranges as possible.
* @param unicodes - unicodes to merge
* @returns merged ranges
*/
function mergeRanges(unicodes) {
return unicodes.reduce((acc, cur) => {
if (acc.length === 0)
return [cur];
const last = acc[acc.length - 1];
// if last is an array, see if we merge
if (Array.isArray(last) && cur === last[1] + 1) {
last[1] = cur;
return acc;
}
else if (typeof last === 'number' && cur === last + 1) {
acc[acc.length - 1] = [last, cur];
return acc;
}
acc.push(cur);
return acc;
}, []);
}
/**
* Convert a number to a base36 string
* @param num - number
* @returns base36 string
*/
function base36(num) {
return num.toString(36);
}
/**
* ID generator. Used to ensure features don't overlap
* @returns a random string
*/
function genID() {
return Math.random().toString(16).replace('0.', '');
}