// Copyright (C) 2024 Rubén Beltrán del Río // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // You should have received a copy of the GNU General Public License // along with this program. If not, see https://map.tranquil.systems. import Combine import CoreData import CoreGraphics import SwiftUI struct MapRenderView: View { var entities: [Wmap.Entity] @Binding var evolution: StageType @State private var lastEntityHash: Int = 0 @AppStorage("showMapBackground") var showMapBackground: Bool = UserPreferences.Defaults .showMapBackground @AppStorage("useCustomFont") var useCustomFont: Bool = UserPreferences.Defaults.useCustomFont @AppStorage("customFontName") var customFontName: String = UserPreferences.Defaults.customFontName @AppStorage("useSmartLabelPositioning") private var useSmartLabelPositioning = UserPreferences .Defaults.useSmartLabelPositioning var stage: Stage { Stage.stages(evolution) } @State private var parsedMap: ParsedMap = ParsedMap.empty @State private var smartLabelPositions: [Int: CGPoint] = [:] @State private var parseTask: Task? let mapSize = Dimensions.Map.size let padding = Dimensions.Map.padding let lineWidth = CGFloat(0.5) let vertexSize = CGSize(width: 25.0, height: 25.0) var onDragVertex: (Vertex, CGFloat, CGFloat) -> Void = { _, _, _ in } private func convertEntitiesInBackground() { parseTask?.cancel() let currentEntities = entities parseTask = Task { let parsed = await Task.detached(priority: .userInitiated) { convertEntitiesToParsedMap(currentEntities) }.value let positions = await Task.detached(priority: .userInitiated) { var positions: [Int: CGPoint] = [:] guard await useSmartLabelPositioning else { return positions } let axisLines = [ Line(start: CGPoint(x: 0, y: 0), end: CGPoint(x: 0, y: mapSize.height)), Line( start: CGPoint(x: 0, y: mapSize.height), end: CGPoint(x: mapSize.width, y: mapSize.height)), ] for vertex in parsed.vertices { positions[vertex.id] = SmartLabelPositioning.calculateOptimalLabelPosition( for: vertex, mapSize: mapSize, vertexSize: vertexSize, edges: parsed.edges, opportunities: parsed.opportunities, inertias: parsed.inertias, axisLines: axisLines, allVertices: parsed.vertices, notes: parsed.notes ) } return positions }.value if !Task.isCancelled { await MainActor.run { parsedMap = parsed smartLabelPositions = positions } } } } private nonisolated func convertEntitiesToParsedMap(_ entities: [Wmap.Entity]) -> ParsedMap { var vertices: [Vertex] = [] var edges: [MapEdge] = [] var inertias: [Inertia] = [] var opportunities: [Opportunity] = [] var notes: [Note] = [] var stages: [CGFloat] = [25.0, 50.0, 75.0] // default dimensions var groups: [[Vertex]] = [] // Build a vertex lookup for edges, groups, etc. var vertexMap: [String: Vertex] = [:] var vertexIdCounter = 0 for entity in entities { switch entity { case .vertex(let vertex): let newVertex = Vertex( id: vertexIdCounter, label: vertex.label, position: CGPoint(x: vertex.coordinates.0, y: vertex.coordinates.1), shape: convertShape(vertex.shape) ) vertices.append(newVertex) vertexMap[vertex.label] = newVertex vertexIdCounter += 1 case .note(let note): notes.append( Note( id: notes.count, position: CGPoint(x: note.coordinates.0, y: note.coordinates.1), text: note.text )) case .stage(let stage): let stageIndex = convertStageNumber(stage.number) - 1 if stageIndex >= 0 && stageIndex < 3 { stages[stageIndex] = stage.value } default: continue } } // Second pass for entities that depend on vertices for entity in entities { switch entity { case .edge(let edge): if let fromVertex = vertexMap[edge.from], let toVertex = vertexMap[edge.to] { edges.append( MapEdge( id: edges.count, origin: fromVertex.position, destination: toVertex.position, arrowhead: edge.isDirected )) } case .inertia(let inertia): if let vertex = vertexMap[inertia.vertex] { inertias.append( Inertia( id: inertias.count, position: vertex.position )) } case .evolution(let evolution): if let vertex = vertexMap[evolution.vertex] { let direction: CGFloat = evolution.isPositive ? 1.0 : -1.0 let destination = CGPoint( x: vertex.position.x + (direction * evolution.value), y: vertex.position.y ) opportunities.append( Opportunity( id: opportunities.count, origin: vertex.position, destination: destination )) } case .group(let group): var groupVertices: [Vertex] = [] for vertexName in group.vertices { if let vertex = vertexMap[vertexName] { groupVertices.append(vertex) } } if !groupVertices.isEmpty { groups.append(groupVertices) } default: continue } } return ParsedMap( vertices: vertices, edges: edges, inertias: inertias, opportunities: opportunities, notes: notes, stages: stages, groups: groups ) } private nonisolated func convertShape(_ shape: String?) -> VertexShape { guard let shape = shape?.lowercased() else { return .circle } switch shape { case "square": return .square case "triangle": return .triangle case "x": return .x default: return .circle } } private nonisolated func convertStageNumber(_ stageNumber: String) -> Int { switch stageNumber.lowercased() { case "i": return 1 case "ii": return 2 case "iii": return 3 case "iv": return 4 default: return 1 } } var body: some View { ZStack(alignment: .topLeading) { Path { path in path.addRect( CGRect( x: -padding, y: -padding, width: mapSize.width + padding * 2, height: mapSize.height + padding * 4)) }.fill(.white) // The order Matters. Think of this as layers. if showMapBackground { MapBackground(mapSize: mapSize, lineWidth: lineWidth, stages: parsedMap.stages) } MapAxes( mapSize: mapSize, lineWidth: lineWidth, evolution: stage, stages: parsedMap.stages) Group { MapStages(mapSize: mapSize, lineWidth: lineWidth, stages: parsedMap.stages) MapEdges( mapSize: mapSize, lineWidth: lineWidth, vertexSize: vertexSize, edges: parsedMap.edges) MapOpportunities( mapSize: mapSize, lineWidth: lineWidth, vertexSize: vertexSize, opportunities: parsedMap.opportunities) MapIntertias(mapSize: mapSize, vertexSize: vertexSize, inertias: parsedMap.inertias) }.mask { MapMask( mapSize: mapSize, vertexSize: vertexSize, vertices: parsedMap.vertices, labelPositions: smartLabelPositions) } MapVertices( mapSize: mapSize, vertexSize: vertexSize, vertices: parsedMap.vertices, labelPositions: smartLabelPositions, onDragVertex: onDragVertex) MapGroups(mapSize: mapSize, vertexSize: vertexSize, groups: parsedMap.groups).drawingGroup( opaque: true ).opacity(0.1) MapNotes( mapSize: mapSize, lineWidth: lineWidth, notes: parsedMap.notes) } .id("\(useCustomFont)-\(customFontName)") .offset(x: padding, y: padding).frame( width: mapSize.width + 2 * padding, height: mapSize.height + 2 * padding, alignment: .topLeading ) .onAppear { convertEntitiesInBackground() } .onChange(of: entities) { _, _ in convertEntitiesInBackground() } .onChange(of: useSmartLabelPositioning) { convertEntitiesInBackground() } .onDisappear { parseTask?.cancel() } } func h(_ dimension: CGFloat) -> CGFloat { max(0.0, min(mapSize.height, dimension * mapSize.height / 100.0)) } func w(_ dimension: CGFloat) -> CGFloat { max(0.0, min(mapSize.width, dimension * mapSize.width / 100.0)) } } #Preview { MapRenderView( entities: [], evolution: Binding.constant(StageType.general) ) }