Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ DerivedData/
*.ipa

src/presets/brandIcons/
src/traffic-signs/node_modules/
27 changes: 27 additions & 0 deletions src/Shared/MapLayersView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class MapLayersView: UIView {
private(set) var gpxLayer: GpxLayer!
private(set) var locatorLayer: MercatorTileLayer!
private(set) var dataOverlayLayer: DataOverlayLayer!
private(set) var trafficSignOverlayLayer: TrafficSignOverlayLayer!
private(set) var quadDownloadLayer: QuadDownloadLayer?
private(set) var mapMarkersView: MapMarkersView!
// collect all of the above layers
Expand Down Expand Up @@ -100,6 +101,16 @@ class MapLayersView: UIView {
}
}

var displayTrafficSignOverlay = false {
didSet {
UserPrefs.shared.mapViewEnableTrafficSigns.value = displayTrafficSignOverlay
trafficSignOverlayLayer.isHidden = !displayTrafficSignOverlay
if displayTrafficSignOverlay {
trafficSignOverlayLayer.refresh()
}
}
}

func initDefaultChildViews(andAlso more: [LayerOrView]) {
for layer in more {
allLayers.append(layer)
Expand Down Expand Up @@ -128,6 +139,12 @@ class MapLayersView: UIView {
dataOverlayLayer.isHidden = true
allLayers.append(dataOverlayLayer)

trafficSignOverlayLayer = TrafficSignOverlayLayer(
viewPort: viewPort,
mapData: mainView.mapView.mapData)
trafficSignOverlayLayer.isHidden = true
allLayers.append(trafficSignOverlayLayer)

mapMarkersView = MapMarkersView(viewPort: viewPort,
mapData: AppDelegate.shared.mainView.mapView.mapData)
mapMarkersView.isHidden = false
Expand Down Expand Up @@ -171,6 +188,16 @@ class MapLayersView: UIView {

// these need to be loaded late because assigning to them changes the view
displayDataOverlayLayers = UserPrefs.shared.mapViewEnableDataOverlay.value ?? false
displayTrafficSignOverlay = UserPrefs.shared.mapViewEnableTrafficSigns.value ?? false

viewPort.mapTransform.onChange.subscribe(self) { [weak self] _ in
guard let self, self.displayTrafficSignOverlay else { return }
self.trafficSignOverlayLayer.refresh()
}

mainView.settings.$displayTrafficSigns.subscribe(self) { [weak self] enabled in
self?.displayTrafficSignOverlay = enabled
}

mainView.settings.$displayGpxTracks.callAndSubscribe(self) { [weak self] displayGpxTracks in
self?.gpxLayer.isHidden = !displayGpxTracks
Expand Down
173 changes: 173 additions & 0 deletions src/Shared/TrafficSigns/TrafficSignCatalog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
//
// TrafficSignCatalog.swift
// Go Map!!
//

import UIKit

/// Keys supported by the traffic-sign value picker.
enum TrafficSignTagKey {
static let all = ["traffic_sign", "traffic_sign:forward", "traffic_sign:backward"]

static func isPickerKey(_ key: String) -> Bool {
all.contains(key)
}
}

struct TrafficSignEntry: Codable, Equatable {
let osmValuePart: String
let signId: String
let name: String
let descriptiveName: String
let kind: String
let imageName: String
let searchTokens: [String]

var assetName: String {
imageName.replacingOccurrences(of: ".svg", with: "")
}
}

struct TrafficSignCountryCatalog: Codable {
let entries: [TrafficSignEntry]
let redirects: [String: String]
let frequent: [String]
}

private struct TrafficSignIndexFile: Codable {
let version: Int
let countries: [String]
let namedTrafficSignValues: [String]
let catalogs: [String: TrafficSignCountryCatalog]
}

/// A catalog sign or unrecognized free-text fragment preserved from an existing tag value.
enum TrafficSignSelectionItem: Equatable {
case catalog(TrafficSignEntry)
case other(osmValuePart: String, displayLabel: String)

var osmValuePart: String {
switch self {
case let .catalog(entry): return entry.osmValuePart
case let .other(part, _): return part
}
}

var isCatalog: Bool {
if case .catalog = self { return true }
return false
}
}

final class TrafficSignCatalog {
static let shared = TrafficSignCatalog()

private let index: TrafficSignIndexFile
private let entriesByOsmPart: [String: [String: TrafficSignEntry]]
private let allEntriesByCountry: [String: [TrafficSignEntry]]

private init() {
let url = Bundle.main.url(forResource: "TrafficSignIndex", withExtension: "json")!
let data = try! Data(contentsOf: url)
index = try! JSONDecoder().decode(TrafficSignIndexFile.self, from: data)

var byPart: [String: [String: TrafficSignEntry]] = [:]
var allByCountry: [String: [TrafficSignEntry]] = [:]
for (code, catalog) in index.catalogs {
let upper = code.uppercased()
var dict: [String: TrafficSignEntry] = [:]
for entry in catalog.entries {
dict[entry.osmValuePart] = entry
}
byPart[upper] = dict
allByCountry[upper] = catalog.entries
}
entriesByOsmPart = byPart
allEntriesByCountry = allByCountry
}

func hasCatalog(forCountryCode countryCode: String) -> Bool {
index.catalogs[countryCode.uppercased()] != nil
}

func countryCodes() -> [String] {
index.countries
}

func namedValues() -> [String] {
index.namedTrafficSignValues
}

func redirects(for countryCode: String) -> [String: String] {
index.catalogs[countryCode.uppercased()]?.redirects ?? [:]
}

func frequentEntries(for countryCode: String) -> [TrafficSignEntry] {
guard let catalog = index.catalogs[countryCode.uppercased()] else { return [] }
return catalog.frequent.compactMap { id in
catalog.entries.first(where: { $0.osmValuePart == id || $0.signId == id })
}
}

func entry(forOsmValuePart part: String, countryCode: String) -> TrafficSignEntry? {
entriesByOsmPart[countryCode.uppercased()]?[part]
}

func entryMatching(signId: String, signValue: String?, countryCode: String) -> TrafficSignEntry? {
let entries = allEntriesByCountry[countryCode.uppercased()] ?? []
let matches = entries.filter { $0.signId == signId }
if matches.count == 1 {
return matches[0]
}
if matches.count > 1 {
if let signValue = signValue {
return matches.first(where: { $0.osmValuePart.contains("[\(signValue)]") })
}
return matches.first(where: { !$0.osmValuePart.contains("[") })
}
return nil
}

func search(query: String, countryCode: String) -> [TrafficSignEntry] {
guard let catalog = index.catalogs[countryCode.uppercased()] else { return [] }
let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if q.isEmpty {
return frequentEntries(for: countryCode)
}
return catalog.entries.filter { entry in
entry.searchTokens.contains(where: { $0.contains(q) })
|| entry.osmValuePart.lowercased().contains(q)
|| entry.descriptiveName.lowercased().contains(q)
|| entry.signId.lowercased().contains(q)
}
}

func image(for entry: TrafficSignEntry) -> UIImage? {
UIImage(named: entry.assetName)
}

func decompose(tagValue: String, countryCode: String) -> [TrafficSignSelectionItem] {
TrafficSignComposer.decompose(tagValue: tagValue, countryCode: countryCode, catalog: self)
}

func compose(selection: [TrafficSignSelectionItem], countryCode: String) -> String {
TrafficSignComposer.compose(selection: selection, countryCode: countryCode, catalog: self)
}

/// Split a tag value into display components for map overlay (catalog icons + text for unknown parts).
func displayComponents(forTagValue tagValue: String, countryCode: String) -> [TrafficSignDisplayComponent] {
decompose(tagValue: tagValue, countryCode: countryCode).map { item in
switch item {
case let .catalog(entry):
return .image(assetName: entry.assetName, label: entry.descriptiveName)
case let .other(part, label):
return .other(label: label.isEmpty ? part : label)
}
}
}
}

enum TrafficSignDisplayComponent {
case image(assetName: String, label: String)
case other(label: String)
}
134 changes: 134 additions & 0 deletions src/Shared/TrafficSigns/TrafficSignComposer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
//
// TrafficSignComposer.swift
// Go Map!!
//
// Offline compose/decompose for traffic_sign values, matching @osm-traffic-signs/converter rules.
//

import Foundation

enum TrafficSignComposer {
static func compose(selection: [TrafficSignSelectionItem],
countryCode: String,
catalog: TrafficSignCatalog) -> String
{
let cc = countryCode.uppercased()
guard !cc.isEmpty, !selection.isEmpty else { return "" }

let named = Set(catalog.namedValues())
var countryPrefixSet = false
var parts: [String] = []

for (index, item) in selection.enumerated() {
let osmPart = item.osmValuePart
let isNamed = named.contains(osmPart)
var countryPrefixString = ""
if !countryPrefixSet, !isNamed {
countryPrefixString = "\(cc):"
countryPrefixSet = true
}

let kind: String = {
if case let .catalog(entry) = item { return entry.kind }
return "traffic_sign"
}()

let isFirst = index == 0
let prevNamed = index > 0 && named.contains(selection[index - 1].osmValuePart)
let separator = isFirst ? "" : (kind == "traffic_sign" || prevNamed ? ";" : ",")
parts.append("\(separator)\(countryPrefixString)\(osmPart)")
}
return parts.joined()
}

static func decompose(tagValue: String,
countryCode: String,
catalog: TrafficSignCatalog) -> [TrafficSignSelectionItem]
{
let cc = countryCode.uppercased()
guard !cc.isEmpty, !tagValue.isEmpty else { return [] }

var cleaned = removeKeys(from: tagValue)
cleaned = removeCountryPrefix(from: cleaned, countryPrefix: cc)
let redirects = catalog.redirects(for: countryCode)
let lowerRedirects = Dictionary(uniqueKeysWithValues: redirects.map { ($0.key.lowercased(), $0.value) })

let valueParts = splitIntoSignValueParts(cleaned).map { part -> String in
lowerRedirects[part.lowercased()] ?? part
}

return valueParts.map { part in
if let entry = catalog.entry(forOsmValuePart: part, countryCode: cc) {
return .catalog(entry)
}
let (signId, signValue) = splitSignIdSignValue(part)
if let entry = catalog.entryMatching(signId: signId, signValue: signValue, countryCode: cc) {
return .catalog(entry)
}
let display = part.hasPrefix("\""), part.hasSuffix("\"") ? String(part.dropFirst().dropLast()) : part
return .other(osmValuePart: part, displayLabel: display)
}
}

// MARK: - Parsing helpers (ported from @osm-traffic-signs/converter)

private static func splitIntoSignValueParts(_ input: String) -> [String] {
var result: [String] = []
var current = ""
var bracketDepth = 0
var inQuotes = false
for char in input {
if char == "\"" {
inQuotes.toggle()
current.append(char)
} else if !inQuotes, char == "[" {
bracketDepth += 1
current.append(char)
} else if !inQuotes, char == "]" {
bracketDepth = max(0, bracketDepth - 1)
current.append(char)
} else if !inQuotes, bracketDepth == 0, char == "," || char == ";" {
let trimmed = current.trimmingCharacters(in: .whitespaces)
if !trimmed.isEmpty { result.append(trimmed) }
current = ""
} else {
current.append(char)
}
}
let trimmed = current.trimmingCharacters(in: .whitespaces)
if !trimmed.isEmpty { result.append(trimmed) }
return result
}

private static func removeCountryPrefix(from input: String, countryPrefix: String) -> String {
input
.replacingOccurrences(of: "\(countryPrefix):", with: "")
.replacingOccurrences(of: "\(countryPrefix.lowercased()):", with: "")
}

private static func removeKeys(from input: String) -> String {
let split = input.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
guard split.count >= 2 else { return input }
let likelyTag = String(split[0])
if likelyTag.contains("traffic_sign") {
return split.dropFirst().joined(separator: "=")
}
return input
}

private static func splitSignIdSignValue(_ urlKey: String) -> (signId: String, signValue: String?) {
if urlKey.hasPrefix("\""), urlKey.hasSuffix("\"") {
return (urlKey, nil)
}
let parts = urlKey.split(separator: "[", maxSplits: 1, omittingEmptySubsequences: false)
let signId = String(parts[0])
if parts.count < 2 {
return (signId, nil)
}
var bracket = String(parts[1])
if bracket.hasSuffix("]") {
bracket.removeLast()
}
return (signId, bracket.isEmpty ? nil : bracket)
}
}
Loading
Loading