2024-05-13 17:21:40 +02:00
import { LocationConflation } from "@rapideditor/location-conflation"
2025-01-08 14:21:07 +01:00
import type { Feature , FeatureCollection , MultiPolygon } from "geojson"
2024-05-16 00:12:50 +02:00
import { Utils } from "../../Utils"
2024-04-30 15:59:07 +02:00
import * as turf from "@turf/turf"
2024-05-16 00:12:50 +02:00
import { Mapping } from "../../Models/ThemeConfig/TagRenderingConfig"
import { Tag } from "../Tags/Tag"
import { TypedTranslation } from "../../UI/i18n/Translation"
import { RegexTag } from "../Tags/RegexTag"
2025-01-10 14:09:54 +01:00
import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
import { TagUtils } from "../Tags/TagUtils"
2025-04-26 22:39:05 +02:00
import Constants from "../../Models/Constants"
2024-05-13 17:21:40 +02:00
/ * *
* Main name suggestion index file
* /
interface NSIFile {
_meta : {
version : string
generated : string
url : string
hash : string
}
nsi : {
[ path : string ] : NSIEntry
}
}
/ * *
* A collection of brands / operators / flagpoles / . . . with common properties
* See https : //github.com/osmlab/name-suggestion-index/wiki/Category-Files for an introduction and
* https : //github.com/osmlab/name-suggestion-index/blob/main/schema/categories.json for a full breakdown
* /
interface NSIEntry {
properties : {
path : string
skipCollection? : boolean
preserveTags? : string [ ]
exclude : unknown
}
items : NSIItem [ ]
}
/ * *
* Represents a single brand / operator / flagpole / . . .
* /
export interface NSIItem {
2025-01-09 20:39:21 +01:00
readonly displayName : string
readonly id : string
2024-05-13 17:21:40 +02:00
locationSet : {
2024-06-16 16:06:26 +02:00
include : string [ ]
2024-05-13 17:21:40 +02:00
exclude : string [ ]
}
2025-01-18 00:30:06 +01:00
readonly tags : Readonly < Record < string , string > >
2025-01-23 02:40:35 +01:00
fromTemplate? : boolean
2025-01-28 15:42:34 +01:00
ext? : string
2024-05-13 17:21:40 +02:00
}
export default class NameSuggestionIndex {
2025-01-02 15:34:59 +01:00
public static readonly supportedTypes = [ "brand" , "flag" , "operator" , "transit" ] as const
2025-01-02 03:38:15 +01:00
private readonly nsiFile : Readonly < NSIFile >
private readonly nsiWdFile : Readonly <
2024-06-16 16:06:26 +02:00
Record <
string ,
{
logos : { wikidata? : string ; facebook? : string }
}
>
2025-01-02 03:38:15 +01:00
>
2024-05-23 04:42:26 +02:00
2025-01-08 14:21:07 +01:00
private loco : LocationConflation // Some additional boundaries
2024-05-13 17:21:40 +02:00
2025-01-02 03:38:15 +01:00
private _supportedTypes : string [ ]
2025-04-26 22:39:05 +02:00
private _serverLocation : string
2025-01-02 03:38:15 +01:00
2025-04-26 22:39:05 +02:00
private constructor (
serverLocation : string ,
2025-01-02 15:34:59 +01:00
nsiFile : Readonly < NSIFile > ,
nsiWdFile : Readonly <
Record <
string ,
{
logos : { wikidata? : string ; facebook? : string }
}
>
2025-01-08 14:21:07 +01:00
> ,
2025-01-18 00:30:06 +01:00
features : Readonly < FeatureCollection >
2025-01-02 15:34:59 +01:00
) {
2025-04-26 22:39:05 +02:00
this . _serverLocation = serverLocation
2025-01-02 03:38:15 +01:00
this . nsiFile = nsiFile
this . nsiWdFile = nsiWdFile
2025-01-08 14:21:07 +01:00
this . loco = new LocationConflation ( features )
2025-01-02 03:38:15 +01:00
}
private static inited : NameSuggestionIndex = undefined
public static async getNsiIndex ( ) : Promise < NameSuggestionIndex > {
if ( NameSuggestionIndex . inited ) {
return NameSuggestionIndex . inited
}
2025-01-08 14:21:07 +01:00
const [ nsi , nsiWd , features ] = await Promise . all (
2025-01-18 00:30:06 +01:00
[
"./assets/data/nsi/nsi.min.json" ,
"./assets/data/nsi/wikidata.min.json" ,
"./assets/data/nsi/featureCollection.min.json" ,
] . map ( ( url ) = > Utils . downloadJsonCached ( url , 1000 * 60 * 60 * 24 * 30 ) )
)
NameSuggestionIndex . inited = new NameSuggestionIndex (
2025-04-26 22:39:05 +02:00
Constants . nsiLogosEndpoint ,
2025-01-18 00:30:06 +01:00
< any > nsi ,
< any > nsiWd [ "wikidata" ] ,
< any > features
2025-01-02 15:34:59 +01:00
)
2025-01-02 03:38:15 +01:00
return NameSuggestionIndex . inited
}
2024-05-13 17:21:40 +02:00
2025-01-02 03:38:15 +01:00
public supportedTypes ( ) : string [ ] {
2024-05-13 17:21:40 +02:00
if ( this . _supportedTypes ) {
return this . _supportedTypes
}
2025-01-02 03:38:15 +01:00
const keys = Object . keys ( this . nsiFile . nsi )
2025-01-02 15:34:59 +01:00
const all = keys . map ( ( k ) = > this . nsiFile . nsi [ k ] . properties . path . split ( "/" ) [ 0 ] )
2024-06-16 16:06:26 +02:00
this . _supportedTypes = Utils . Dedup ( all ) . map ( ( s ) = > {
if ( s . endsWith ( "s" ) ) {
2024-05-23 04:42:26 +02:00
s = s . substring ( 0 , s . length - 1 )
}
return s
} )
2024-05-13 17:21:40 +02:00
return this . _supportedTypes
}
2024-05-16 00:12:50 +02:00
/ * *
* Fetches the data files for a single country . Note that it contains _all_ entries having this brand , not for a single type of object
* @param type
* @param countries
* @private
* /
2025-04-26 22:39:05 +02:00
private async fetchFrequenciesFor ( type : string , countries : string [ ] ) {
const server = this . _serverLocation
2024-06-16 16:06:26 +02:00
let stats = await Promise . all (
countries . map ( ( c ) = > {
try {
return Utils . downloadJsonCached < Record < string , number > > (
2025-04-26 22:39:05 +02:00
` ${ server } /stats/ ${ type } . ${ c . toUpperCase ( ) } .json ` ,
2025-01-18 00:30:06 +01:00
24 * 60 * 60 * 1000
2024-06-16 16:06:26 +02:00
)
} catch ( e ) {
console . error ( "Could not fetch " + type + " statistics due to" , e )
return undefined
}
2025-01-18 00:30:06 +01:00
} )
2024-06-16 16:06:26 +02:00
)
2024-05-16 00:12:50 +02:00
stats = Utils . NoNull ( stats )
if ( stats . length === 1 ) {
return stats [ 0 ]
}
2024-06-16 16:06:26 +02:00
if ( stats . length === 0 ) {
2024-05-23 04:42:26 +02:00
return { }
}
2024-05-16 00:12:50 +02:00
const merged = stats [ 0 ]
for ( let i = 1 ; i < stats . length ; i ++ ) {
for ( const countryCode in stats [ i ] ) {
merged [ countryCode ] = ( merged [ countryCode ] ? ? 0 ) + stats [ i ] [ countryCode ]
}
}
return merged
}
2025-01-02 03:38:15 +01:00
public isSvg ( nsiItem : NSIItem , type : string ) : boolean | undefined {
if ( this . nsiWdFile === undefined ) {
2025-01-02 15:34:59 +01:00
throw (
"nsiWdi file is not loaded, cannot determine if " + nsiItem . id + " has an SVG image"
)
2025-01-02 03:38:15 +01:00
}
2024-05-23 04:42:26 +02:00
const logos = this . nsiWdFile [ nsiItem ? . tags ? . [ type + ":wikidata" ] ] ? . logos
2024-05-16 00:12:50 +02:00
if ( ! logos ) {
return undefined
}
if ( logos . facebook ) {
return false
2024-05-13 17:21:40 +02:00
}
2024-05-16 00:12:50 +02:00
const url : string = logos . wikidata
if ( url . toLowerCase ( ) . endsWith ( ".svg" ) ) {
return true
}
return false
}
2025-01-02 03:38:15 +01:00
public async generateMappings (
2024-06-16 16:06:26 +02:00
type : string ,
tags : Record < string , string > ,
2025-01-09 20:39:21 +01:00
country? : string [ ] ,
2024-08-09 14:43:30 +02:00
location ? : [ number , number ] ,
2024-08-14 13:53:56 +02:00
options ? : {
2024-08-09 14:43:30 +02:00
/ * *
* If set , sort by frequency instead of alphabetically
* /
sortByFrequency : boolean
2025-01-18 00:30:06 +01:00
}
2024-06-16 16:06:26 +02:00
) : Promise < Mapping [ ] > {
2024-08-14 13:53:56 +02:00
const mappings : ( Mapping & { frequency : number } ) [ ] = [ ]
2025-01-18 00:30:06 +01:00
const frequencies =
country !== undefined
2025-04-26 22:39:05 +02:00
? await this . fetchFrequenciesFor ( type , country )
2025-01-18 00:30:06 +01:00
: { }
2024-05-23 04:42:26 +02:00
for ( const key in tags ) {
if ( key . startsWith ( "_" ) ) {
continue
}
const value = tags [ key ]
2025-01-02 03:38:15 +01:00
const actualBrands = this . getSuggestionsForKV (
2024-06-16 16:06:26 +02:00
type ,
key ,
value ,
country . join ( ";" ) ,
2025-01-18 00:30:06 +01:00
location
2024-06-16 16:06:26 +02:00
)
if ( ! actualBrands ) {
2024-05-23 04:42:26 +02:00
continue
}
for ( const nsiItem of actualBrands ) {
const tags = nsiItem . tags
const frequency = frequencies [ nsiItem . displayName ]
2025-01-27 23:26:17 +01:00
const icon = this . getIconUrl ( nsiItem )
2024-05-23 04:42:26 +02:00
mappings . push ( {
if : new Tag ( type , tags [ type ] ) ,
2024-06-16 16:06:26 +02:00
addExtraTags : Object.keys ( tags )
. filter ( ( k ) = > k !== type )
. map ( ( k ) = > new Tag ( k , tags [ k ] ) ) ,
2024-08-09 14:48:29 +02:00
then : new TypedTranslation < Record < string , never > > ( { "*" : nsiItem . displayName } ) ,
2024-05-23 04:42:26 +02:00
hideInAnswer : false ,
ifnot : undefined ,
alsoShowIf : undefined ,
icon ,
iconClass : "medium" ,
2024-07-09 10:54:41 +02:00
// The 'frequency' is already for the country of the object we are working with
// As such, it should be "true" but this is not supported
2024-05-23 04:42:26 +02:00
priorityIf : frequency > 0 ? new RegexTag ( "id" , /.*/ ) : undefined ,
2024-06-16 16:06:26 +02:00
searchTerms : { "*" : [ nsiItem . displayName , nsiItem . id ] } ,
2024-08-14 13:53:56 +02:00
frequency : frequency ? ? - 1 ,
2024-05-23 04:42:26 +02:00
} )
2024-05-16 00:12:50 +02:00
}
}
2024-08-14 13:53:56 +02:00
if ( options ? . sortByFrequency ) {
2024-08-09 14:43:30 +02:00
mappings . sort ( ( a , b ) = > b . frequency - a . frequency )
}
2024-05-16 00:12:50 +02:00
return mappings
2024-05-13 17:21:40 +02:00
}
2025-01-02 03:38:15 +01:00
public supportedTags (
2025-01-18 00:30:06 +01:00
type : "operator" | "brand" | "flag" | "transit" | string
2024-06-16 16:06:26 +02:00
) : Record < string , string [ ] > {
const tags : Record < string , string [ ] > = { }
2025-01-02 03:38:15 +01:00
const keys = Object . keys ( this . nsiFile . nsi )
2024-05-13 17:21:40 +02:00
for ( const key of keys ) {
2025-01-02 03:38:15 +01:00
const nsiItem = this . nsiFile . nsi [ key ]
2024-05-13 17:21:40 +02:00
const path = nsiItem . properties . path
const [ osmType , osmkey , osmvalue ] = path . split ( "/" )
2024-06-16 16:06:26 +02:00
if ( type !== osmType && type + "s" !== osmType ) {
2024-05-13 17:21:40 +02:00
continue
}
if ( ! tags [ osmkey ] ) {
tags [ osmkey ] = [ ]
}
tags [ osmkey ] . push ( osmvalue )
}
return tags
}
2024-05-16 00:12:50 +02:00
/ * *
* Returns a list of all brands / operators
* @param type
* /
2025-01-09 20:39:21 +01:00
public allPossible ( type : string ) : NSIItem [ ] {
2024-05-16 00:12:50 +02:00
const options : NSIItem [ ] = [ ]
2025-01-02 03:38:15 +01:00
const tags = this . supportedTags ( type )
2024-05-13 17:21:40 +02:00
for ( const osmKey in tags ) {
const values = tags [ osmKey ]
for ( const osmValue of values ) {
2024-05-23 04:42:26 +02:00
const suggestions = this . getSuggestionsForKV ( type , osmKey , osmValue )
2025-01-28 15:42:34 +01:00
if ( ! suggestions ) {
2025-01-23 02:40:35 +01:00
console . warn ( "No suggestions found for" , type , osmKey , osmValue )
continue
}
2024-05-16 00:12:50 +02:00
options . push ( . . . suggestions )
2024-05-13 17:21:40 +02:00
}
}
2024-06-16 16:06:26 +02:00
return options
2024-05-13 17:21:40 +02:00
}
/ * *
*
2024-05-16 00:12:50 +02:00
* @param country : a string containing one or more country codes , separated by ";"
2024-05-13 17:21:40 +02:00
* @param location : center point of the feature , should be [ lon , lat ]
* /
2025-01-02 03:38:15 +01:00
public getSuggestionsFor (
2024-06-16 16:06:26 +02:00
type : string ,
tags : { key : string ; value : string } [ ] ,
country : string = undefined ,
2025-01-18 00:30:06 +01:00
location : [ number , number ] = undefined
2024-06-16 16:06:26 +02:00
) : NSIItem [ ] {
return tags . flatMap ( ( tag ) = >
2025-01-18 00:30:06 +01:00
this . getSuggestionsForKV ( type , tag . key , tag . value , country , location )
2024-06-16 16:06:26 +02:00
)
2024-05-23 04:42:26 +02:00
}
/ * *
2024-09-18 16:52:02 +02:00
* Caching for the resolved sets , as they can take a while
* @private
* /
private static resolvedSets : Record < string , any > = { }
/ * *
* Returns all suggestions for the given type ( brand | operator ) and main tag .
* Can optionally be filtered by countries and location set
*
2024-05-23 04:42:26 +02:00
*
* @param country : a string containing one or more country codes , separated by ";"
* @param location : center point of the feature , should be [ lon , lat ]
* /
2025-01-02 03:38:15 +01:00
public getSuggestionsForKV (
2024-06-16 16:06:26 +02:00
type : string ,
key : string ,
value : string ,
country : string = undefined ,
2025-01-18 00:30:06 +01:00
location : [ number , number ] = undefined
2024-06-16 16:06:26 +02:00
) : NSIItem [ ] {
2024-05-13 17:21:40 +02:00
const path = ` ${ type } s/ ${ key } / ${ value } `
2025-01-02 03:38:15 +01:00
const entry = this . nsiFile . nsi [ path ]
2024-09-18 16:52:02 +02:00
const countries = country ? . split ( ";" ) ? ? [ ]
2024-06-16 16:06:26 +02:00
return entry ? . items ? . filter ( ( i ) = > {
2024-05-13 17:21:40 +02:00
if ( i . locationSet . include . indexOf ( "001" ) >= 0 ) {
2024-09-18 16:52:02 +02:00
// this brand is spread globally
return true
}
if ( country === undefined ) {
// IF the country is not set, we are probably in some international area, it isn't loaded yet,
// or we are in a code path where we need everything (e.g. a script)
// We just allow everything
2024-05-13 17:21:40 +02:00
return true
}
2024-10-19 14:44:55 +02:00
if ( i . locationSet . include . some ( ( c ) = > countries . indexOf ( c ) >= 0 ) ) {
2024-09-18 16:52:02 +02:00
// We prefer the countries provided by lonlat2country, they are more precise and are loaded already anyway (cheap)
// Country might contain multiple countries, separated by ';'
2024-05-13 17:21:40 +02:00
return true
}
2024-10-19 14:44:55 +02:00
if ( i . locationSet . exclude ? . some ( ( c ) = > countries . indexOf ( c ) >= 0 ) ) {
2024-09-18 16:52:02 +02:00
return false
}
2024-05-13 17:21:40 +02:00
if ( location === undefined ) {
return true
}
2024-09-18 16:52:02 +02:00
2024-10-19 14:44:55 +02:00
const hasSpecial =
2024-12-26 23:19:47 +01:00
i . locationSet . include ? . some ( ( i ) = > Array . isArray ( i ) || i . endsWith ( ".geojson" ) ) ||
i . locationSet . exclude ? . some ( ( i ) = > Array . isArray ( i ) || i . endsWith ( ".geojson" ) )
2024-09-18 16:52:02 +02:00
if ( ! hasSpecial ) {
return false
}
const key = i . locationSet . include ? . join ( ";" ) + "-" + i . locationSet . exclude ? . join ( ";" )
const fromCache = NameSuggestionIndex . resolvedSets [ key ]
2025-01-18 00:30:06 +01:00
const resolvedSet = fromCache ? ? this . loco . resolveLocationSet ( i . locationSet )
2024-09-18 16:52:02 +02:00
if ( ! fromCache ) {
NameSuggestionIndex . resolvedSets [ key ] = resolvedSet
}
2024-05-13 17:21:40 +02:00
if ( resolvedSet ) {
// We actually have a location set, so we can check if the feature is in it, by determining if our point is inside the MultiPolygon using @turf/boolean-point-in-polygon
// This might occur for some extra boundaries, such as counties, ...
const setFeature : Feature < MultiPolygon > = resolvedSet . feature
return turf . booleanPointInPolygon ( location , setFeature . geometry )
}
return false
} )
}
2025-01-02 03:38:15 +01:00
2025-01-02 15:34:59 +01:00
public static async generateMappings (
key : string ,
tags : Exclude < Record < string , string > , undefined | null > ,
country : string [ ] ,
center : [ number , number ] ,
options : {
sortByFrequency : boolean
2025-01-18 00:30:06 +01:00
}
2025-01-02 15:34:59 +01:00
) : Promise < Mapping [ ] > {
2025-01-02 03:38:15 +01:00
const nsi = await NameSuggestionIndex . getNsiIndex ( )
return nsi . generateMappings ( key , tags , country , center , options )
}
2025-01-09 20:39:21 +01:00
/ * *
* Where can we find the URL on the world wide web ?
* Probably facebook ! Don ' t use in the website , might expose people
* @param nsiItem
* @param type
* /
private getIconExternalUrl ( nsiItem : NSIItem , type : string ) : string {
const logos = this . nsiWdFile [ nsiItem . tags [ type + ":wikidata" ] ] ? . logos
return logos ? . facebook ? ? logos ? . wikidata
}
2025-01-26 22:05:44 +01:00
public getIconUrl ( nsiItem : NSIItem ) : string | undefined {
2025-04-26 22:39:05 +02:00
const baseUrl = this . _serverLocation
if ( ! nsiItem . ext || baseUrl === null ) {
2025-01-26 22:05:44 +01:00
// No extension -> there is no logo
return undefined
}
2025-04-26 22:39:05 +02:00
return baseUrl + "/logos/" + nsiItem . id + "." + nsiItem . ext
2025-01-09 20:39:21 +01:00
}
2025-01-26 22:05:44 +01:00
2025-01-18 00:30:06 +01:00
private static readonly brandPrefix = [ "name" , "alt_name" , "operator" , "brand" ] as const
2025-01-10 14:09:54 +01:00
/ * *
* An NSI - item might have tags such as ` name=X ` , ` alt_name=brand X ` , ` brand=X ` , ` brand:wikidata ` , ` shop=Y ` , ` service:abc=yes `
* Many of those tags are all added , but having only one of them is a good indication that it should match this item .
*
* This method is a heuristic which attempts to move all the brand - related tags into an ` or ` but still requiring the ` shop ` and other tags
*
* ( More of an extension method on NSIItem )
* /
2025-01-18 00:30:06 +01:00
static asFilterTags (
item : NSIItem
) : string | { and : TagConfigJson [ ] } | { or : TagConfigJson [ ] } {
2025-01-10 14:09:54 +01:00
let brandDetection : string [ ] = [ ]
let required : string [ ] = [ ]
const tags : Record < string , string > = item . tags
for ( const k in tags ) {
2025-01-18 00:30:06 +01:00
if ( NameSuggestionIndex . brandPrefix . some ( ( br ) = > k === br || k . startsWith ( br + ":" ) ) ) {
2025-01-10 14:09:54 +01:00
brandDetection . push ( k + "=" + tags [ k ] )
} else {
required . push ( k + "=" + tags [ k ] )
}
}
return < TagConfigJson > TagUtils . optimzeJson ( { and : [ . . . required , { or : brandDetection } ] } )
}
2024-05-13 17:21:40 +02:00
}