]> git.r.bdr.sh - rbdr/map/commitdiff
Add code for first release
authorRuben Beltran del Rio <redacted>
Wed, 3 Feb 2021 22:53:12 +0000 (23:53 +0100)
committerRuben Beltran del Rio <redacted>
Wed, 3 Feb 2021 22:53:12 +0000 (23:53 +0100)
48 files changed:
CONTRIBUTING.md [new file with mode: 0644]
LICENSE [new file with mode: 0644]
Makefile [new file with mode: 0644]
Map.xcodeproj/project.pbxproj
Map/Assets.xcassets/AccentColor.colorset/Contents.json
Map/Assets.xcassets/AppIcon.appiconset/Contents.json
Map/Assets.xcassets/AppIcon.appiconset/icon_128x128.png [new file with mode: 0644]
Map/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png [new file with mode: 0644]
Map/Assets.xcassets/AppIcon.appiconset/icon_16x16.png [new file with mode: 0644]
Map/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png [new file with mode: 0644]
Map/Assets.xcassets/AppIcon.appiconset/icon_256x256.png [new file with mode: 0644]
Map/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png [new file with mode: 0644]
Map/Assets.xcassets/AppIcon.appiconset/icon_32x32.png [new file with mode: 0644]
Map/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png [new file with mode: 0644]
Map/Assets.xcassets/AppIcon.appiconset/icon_512x512.png [new file with mode: 0644]
Map/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png [new file with mode: 0644]
Map/ContentView.swift [deleted file]
Map/Extensions/Binding+unwrap.swift [deleted file]
Map/Extensions/Date+format.swift
Map/Extensions/Map+parse.swift
Map/Extensions/NSImage+writePNG.swift [new file with mode: 0644]
Map/Info.plist
Map/Map.entitlements
Map/MapApp.swift [deleted file]
Map/MapDetail.swift [deleted file]
Map/MapRender.swift [deleted file]
Map/MapRenderComponents/MapAxes.swift [new file with mode: 0644]
Map/MapRenderComponents/MapBlockers.swift [new file with mode: 0644]
Map/MapRenderComponents/MapColor.swift [new file with mode: 0644]
Map/MapRenderComponents/MapEdges.swift [new file with mode: 0644]
Map/MapRenderComponents/MapOpportunities.swift [new file with mode: 0644]
Map/MapRenderComponents/MapStages.swift [new file with mode: 0644]
Map/MapRenderComponents/MapVertices.swift [new file with mode: 0644]
Map/Persistence.swift [deleted file]
Map/Stage.swift [deleted file]
Map/State/AppState.swift [new file with mode: 0644]
Map/State/Persistence.swift [new file with mode: 0644]
Map/State/Store.swift [new file with mode: 0644]
Map/Views/ContentView.swift [new file with mode: 0644]
Map/Views/DefaultMapView.swift [new file with mode: 0644]
Map/Views/MapApp.swift [new file with mode: 0644]
Map/Views/MapDetail.swift [new file with mode: 0644]
Map/Views/MapRender.swift [new file with mode: 0644]
Map/Views/MapTextEditor.swift [new file with mode: 0644]
Map/Views/Stage.swift [new file with mode: 0644]
MapTests/MapTests.swift
MapUITests/MapUITests.swift
README.md [new file with mode: 0644]

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644 (file)
index 0000000..5e112f7
--- /dev/null
@@ -0,0 +1,29 @@
+# Contributing to Map
+
+Contributions are welcome! If you find this app helpful and would like to
+help, please read on.
+
+## Why does this app exist?
+
+I use wardley maps for many things, and I find that most diagram apps
+are cumbersome to use for this, and pen and paper is hard to share.
+
+I created this app inspired by graphviz, hoping to create a language
+that allows for the easy creation, modification and sharing of wardley maps.
+
+### What kind of contributions are welcome?
+
+Any improvements on the existing features, and bugfixes are welcome.
+
+If you would like to add a new feature, please add an issue
+before submitting code, so we can discuss; also, feel free to fork!
+
+## How to contribute
+
+Above All: Be nice, always.
+
+Befoe opening a merge request:
+
+* Run `make format`
+* Ensure `make lint` shows no warnings or errors
+* Make a merge request describing what/why is included in your changes
diff --git a/LICENSE b/LICENSE
new file mode 100644 (file)
index 0000000..497db7d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "{}"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright 2018 Rubén Beltran del Río
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..fe56dfc
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,15 @@
+swift_version = 5.3
+
+format:
+       swift format -i -r .
+
+lint:
+       swift format -m lint -r .
+
+docker-build:
+       docker build --force-rm --build-arg swift_version=$(swift_version) -t doapp/do:$(swift_version) .
+
+docker-push: docker-build
+       docker push doapp/do:$(swift_version)
+
+.PHONY: format lint docker-build docker-push
index b7dc2fa9465e087986e91f98dc6e5c8fb58427c9..275bb9e25733a738c6b66e703e813aaa7a423a84 100644 (file)
@@ -7,6 +7,16 @@
        objects = {
 
 /* Begin PBXBuildFile section */
+               B523C73D25C98D9800C44061 /* NSImage+writePNG.swift in Sources */ = {isa = PBXBuildFile; fileRef = B523C73C25C98D9800C44061 /* NSImage+writePNG.swift */; };
+               B523C74625C9BD3500C44061 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B523C74525C9BD3500C44061 /* CloudKit.framework */; };
+               B523C74B25C9C1BA00C44061 /* DefaultMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B523C74A25C9C1BA00C44061 /* DefaultMapView.swift */; };
+               B523C75A25C9FD4900C44061 /* MapAxes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B523C75925C9FD4900C44061 /* MapAxes.swift */; };
+               B523C76225CA05A300C44061 /* MapStages.swift in Sources */ = {isa = PBXBuildFile; fileRef = B523C76125CA05A300C44061 /* MapStages.swift */; };
+               B523C76725CA071B00C44061 /* MapVertices.swift in Sources */ = {isa = PBXBuildFile; fileRef = B523C76625CA071B00C44061 /* MapVertices.swift */; };
+               B523C76C25CA0DFA00C44061 /* MapEdges.swift in Sources */ = {isa = PBXBuildFile; fileRef = B523C76B25CA0DFA00C44061 /* MapEdges.swift */; };
+               B523C77125CA121300C44061 /* MapBlockers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B523C77025CA121300C44061 /* MapBlockers.swift */; };
+               B523C77625CA181100C44061 /* MapColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B523C77525CA181100C44061 /* MapColor.swift */; };
+               B523C77E25CA294C00C44061 /* MapOpportunities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B523C77D25CA294C00C44061 /* MapOpportunities.swift */; };
                B526257225C874F9003E73B7 /* MapApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B526257125C874F9003E73B7 /* MapApp.swift */; };
                B526257425C874F9003E73B7 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B526257325C874F9003E73B7 /* ContentView.swift */; };
                B526257625C874FA003E73B7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B526257525C874FA003E73B7 /* Assets.xcassets */; };
                B52625A625C876C3003E73B7 /* MapDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52625A525C876C3003E73B7 /* MapDetail.swift */; };
                B52625AB25C87909003E73B7 /* Date+format.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52625AA25C87909003E73B7 /* Date+format.swift */; };
                B52625B025C87C14003E73B7 /* MapRender.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52625AF25C87C14003E73B7 /* MapRender.swift */; };
-               B52625B625C87D69003E73B7 /* Binding+unwrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52625B525C87D69003E73B7 /* Binding+unwrap.swift */; };
                B52625BB25C884C2003E73B7 /* Map+parse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52625BA25C884C2003E73B7 /* Map+parse.swift */; };
                B52625C625C8BD2A003E73B7 /* Stage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52625C525C8BD2A003E73B7 /* Stage.swift */; };
+               B539516C25CB0C9300959F72 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = B539516B25CB0C9200959F72 /* Store.swift */; };
+               B539517425CB0CA400959F72 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B539517325CB0CA400959F72 /* AppState.swift */; };
+               B539518125CB2D7A00959F72 /* MapTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B539518025CB2D7A00959F72 /* MapTextEditor.swift */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
 /* End PBXContainerItemProxy section */
 
 /* Begin PBXFileReference section */
+               B523C73C25C98D9800C44061 /* NSImage+writePNG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSImage+writePNG.swift"; sourceTree = "<group>"; };
+               B523C74525C9BD3500C44061 /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; };
+               B523C74A25C9C1BA00C44061 /* DefaultMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultMapView.swift; sourceTree = "<group>"; };
+               B523C75925C9FD4900C44061 /* MapAxes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapAxes.swift; sourceTree = "<group>"; };
+               B523C76125CA05A300C44061 /* MapStages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapStages.swift; sourceTree = "<group>"; };
+               B523C76625CA071B00C44061 /* MapVertices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapVertices.swift; sourceTree = "<group>"; };
+               B523C76B25CA0DFA00C44061 /* MapEdges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapEdges.swift; sourceTree = "<group>"; };
+               B523C77025CA121300C44061 /* MapBlockers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapBlockers.swift; sourceTree = "<group>"; };
+               B523C77525CA181100C44061 /* MapColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapColor.swift; sourceTree = "<group>"; };
+               B523C77D25CA294C00C44061 /* MapOpportunities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapOpportunities.swift; sourceTree = "<group>"; };
                B526256E25C874F9003E73B7 /* Map.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Map.app; sourceTree = BUILT_PRODUCTS_DIR; };
                B526257125C874F9003E73B7 /* MapApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapApp.swift; sourceTree = "<group>"; };
                B526257325C874F9003E73B7 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
                B52625A525C876C3003E73B7 /* MapDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDetail.swift; sourceTree = "<group>"; };
                B52625AA25C87909003E73B7 /* Date+format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+format.swift"; sourceTree = "<group>"; };
                B52625AF25C87C14003E73B7 /* MapRender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapRender.swift; sourceTree = "<group>"; };
-               B52625B525C87D69003E73B7 /* Binding+unwrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Binding+unwrap.swift"; sourceTree = "<group>"; };
                B52625BA25C884C2003E73B7 /* Map+parse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Map+parse.swift"; sourceTree = "<group>"; };
                B52625C525C8BD2A003E73B7 /* Stage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stage.swift; sourceTree = "<group>"; };
+               B539516B25CB0C9200959F72 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
+               B539517325CB0CA400959F72 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
+               B539518025CB2D7A00959F72 /* MapTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTextEditor.swift; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFrameworksBuildPhase section */
@@ -69,6 +93,7 @@
                        isa = PBXFrameworksBuildPhase;
                        buildActionMask = 2147483647;
                        files = (
+                               B523C74625C9BD3500C44061 /* CloudKit.framework in Frameworks */,
                        );
                        runOnlyForDeploymentPostprocessing = 0;
                };
 /* End PBXFrameworksBuildPhase section */
 
 /* Begin PBXGroup section */
+               B523C74425C9BD3500C44061 /* Frameworks */ = {
+                       isa = PBXGroup;
+                       children = (
+                               B523C74525C9BD3500C44061 /* CloudKit.framework */,
+                       );
+                       name = Frameworks;
+                       sourceTree = "<group>";
+               };
+               B523C75825C9FD3A00C44061 /* MapRenderComponents */ = {
+                       isa = PBXGroup;
+                       children = (
+                               B523C75925C9FD4900C44061 /* MapAxes.swift */,
+                               B523C76125CA05A300C44061 /* MapStages.swift */,
+                               B523C76625CA071B00C44061 /* MapVertices.swift */,
+                               B523C76B25CA0DFA00C44061 /* MapEdges.swift */,
+                               B523C77025CA121300C44061 /* MapBlockers.swift */,
+                               B523C77525CA181100C44061 /* MapColor.swift */,
+                               B523C77D25CA294C00C44061 /* MapOpportunities.swift */,
+                       );
+                       path = MapRenderComponents;
+                       sourceTree = "<group>";
+               };
                B526256525C874F9003E73B7 = {
                        isa = PBXGroup;
                        children = (
                                B526258825C874FA003E73B7 /* MapTests */,
                                B526259325C874FA003E73B7 /* MapUITests */,
                                B526256F25C874F9003E73B7 /* Products */,
+                               B523C74425C9BD3500C44061 /* Frameworks */,
                        );
                        sourceTree = "<group>";
                };
                B526257025C874F9003E73B7 /* Map */ = {
                        isa = PBXGroup;
                        children = (
+                               B539516A25CB0C7800959F72 /* State */,
                                B52625B425C87D54003E73B7 /* Extensions */,
-                               B526257125C874F9003E73B7 /* MapApp.swift */,
-                               B526257325C874F9003E73B7 /* ContentView.swift */,
+                               B539517925CB0D6100959F72 /* Views */,
+                               B523C75825C9FD3A00C44061 /* MapRenderComponents */,
                                B526257525C874FA003E73B7 /* Assets.xcassets */,
-                               B526257A25C874FA003E73B7 /* Persistence.swift */,
                                B526257F25C874FA003E73B7 /* Info.plist */,
                                B526258025C874FA003E73B7 /* Map.entitlements */,
                                B526257C25C874FA003E73B7 /* Map.xcdatamodeld */,
                                B526257725C874FA003E73B7 /* Preview Content */,
-                               B52625A525C876C3003E73B7 /* MapDetail.swift */,
-                               B52625AF25C87C14003E73B7 /* MapRender.swift */,
-                               B52625C525C8BD2A003E73B7 /* Stage.swift */,
                        );
                        path = Map;
                        sourceTree = "<group>";
                        children = (
                                B52625BA25C884C2003E73B7 /* Map+parse.swift */,
                                B52625AA25C87909003E73B7 /* Date+format.swift */,
-                               B52625B525C87D69003E73B7 /* Binding+unwrap.swift */,
+                               B523C73C25C98D9800C44061 /* NSImage+writePNG.swift */,
                        );
                        path = Extensions;
                        sourceTree = "<group>";
                };
+               B539516A25CB0C7800959F72 /* State */ = {
+                       isa = PBXGroup;
+                       children = (
+                               B526257A25C874FA003E73B7 /* Persistence.swift */,
+                               B539516B25CB0C9200959F72 /* Store.swift */,
+                               B539517325CB0CA400959F72 /* AppState.swift */,
+                       );
+                       path = State;
+                       sourceTree = "<group>";
+               };
+               B539517925CB0D6100959F72 /* Views */ = {
+                       isa = PBXGroup;
+                       children = (
+                               B526257125C874F9003E73B7 /* MapApp.swift */,
+                               B526257325C874F9003E73B7 /* ContentView.swift */,
+                               B52625A525C876C3003E73B7 /* MapDetail.swift */,
+                               B52625C525C8BD2A003E73B7 /* Stage.swift */,
+                               B523C74A25C9C1BA00C44061 /* DefaultMapView.swift */,
+                               B539518025CB2D7A00959F72 /* MapTextEditor.swift */,
+                               B52625AF25C87C14003E73B7 /* MapRender.swift */,
+                       );
+                       path = Views;
+                       sourceTree = "<group>";
+               };
 /* End PBXGroup section */
 
 /* Begin PBXNativeTarget section */
                        buildActionMask = 2147483647;
                        files = (
                                B52625B025C87C14003E73B7 /* MapRender.swift in Sources */,
+                               B523C77E25CA294C00C44061 /* MapOpportunities.swift in Sources */,
                                B52625AB25C87909003E73B7 /* Date+format.swift in Sources */,
-                               B52625B625C87D69003E73B7 /* Binding+unwrap.swift in Sources */,
+                               B523C77125CA121300C44061 /* MapBlockers.swift in Sources */,
+                               B523C76725CA071B00C44061 /* MapVertices.swift in Sources */,
+                               B539517425CB0CA400959F72 /* AppState.swift in Sources */,
+                               B523C75A25C9FD4900C44061 /* MapAxes.swift in Sources */,
+                               B539518125CB2D7A00959F72 /* MapTextEditor.swift in Sources */,
                                B52625BB25C884C2003E73B7 /* Map+parse.swift in Sources */,
                                B52625C625C8BD2A003E73B7 /* Stage.swift in Sources */,
+                               B523C73D25C98D9800C44061 /* NSImage+writePNG.swift in Sources */,
                                B526257B25C874FA003E73B7 /* Persistence.swift in Sources */,
+                               B523C77625CA181100C44061 /* MapColor.swift in Sources */,
                                B526257425C874F9003E73B7 /* ContentView.swift in Sources */,
                                B526257E25C874FA003E73B7 /* Map.xcdatamodeld in Sources */,
+                               B523C74B25C9C1BA00C44061 /* DefaultMapView.swift in Sources */,
+                               B539516C25CB0C9300959F72 /* Store.swift in Sources */,
                                B526257225C874F9003E73B7 /* MapApp.swift in Sources */,
                                B52625A625C876C3003E73B7 /* MapDetail.swift in Sources */,
+                               B523C76225CA05A300C44061 /* MapStages.swift in Sources */,
+                               B523C76C25CA0DFA00C44061 /* MapEdges.swift in Sources */,
                        );
                        runOnlyForDeploymentPostprocessing = 0;
                };
                                        "$(inherited)",
                                        "@executable_path/../Frameworks",
                                );
-                               MACOSX_DEPLOYMENT_TARGET = 11.0;
+                               MACOSX_DEPLOYMENT_TARGET = 11;
+                               MARKETING_VERSION = 1.0.0;
                                PRODUCT_BUNDLE_IDENTIFIER = pizza.unlimited.Map;
                                PRODUCT_NAME = "$(TARGET_NAME)";
                                SWIFT_VERSION = 5.0;
                                        "$(inherited)",
                                        "@executable_path/../Frameworks",
                                );
-                               MACOSX_DEPLOYMENT_TARGET = 11.0;
+                               MACOSX_DEPLOYMENT_TARGET = 11;
+                               MARKETING_VERSION = 1.0.0;
                                PRODUCT_BUNDLE_IDENTIFIER = pizza.unlimited.Map;
                                PRODUCT_NAME = "$(TARGET_NAME)";
                                SWIFT_VERSION = 5.0;
index eb8789700816459c1e1480e0b34781d9fb78a1ca..c981db70309e4ea0ef77fc1200264bfe44531e87 100644 (file)
@@ -1,6 +1,15 @@
 {
   "colors" : [
     {
+      "color" : {
+        "color-space" : "srgb",
+        "components" : {
+          "alpha" : "1.000",
+          "blue" : "0.969",
+          "green" : "0.871",
+          "red" : "1.000"
+        }
+      },
       "idiom" : "universal"
     }
   ],
index 3f00db43ec3c8b462759505d635dc5545d4e8e50..64dc11ee7438ff9d41f0fb625e861d6bb881024c 100644 (file)
@@ -1,51 +1,61 @@
 {
   "images" : [
     {
+      "filename" : "icon_16x16.png",
       "idiom" : "mac",
       "scale" : "1x",
       "size" : "16x16"
     },
     {
+      "filename" : "icon_16x16@2x.png",
       "idiom" : "mac",
       "scale" : "2x",
       "size" : "16x16"
     },
     {
+      "filename" : "icon_32x32.png",
       "idiom" : "mac",
       "scale" : "1x",
       "size" : "32x32"
     },
     {
+      "filename" : "icon_32x32@2x.png",
       "idiom" : "mac",
       "scale" : "2x",
       "size" : "32x32"
     },
     {
+      "filename" : "icon_128x128.png",
       "idiom" : "mac",
       "scale" : "1x",
       "size" : "128x128"
     },
     {
+      "filename" : "icon_128x128@2x.png",
       "idiom" : "mac",
       "scale" : "2x",
       "size" : "128x128"
     },
     {
+      "filename" : "icon_256x256.png",
       "idiom" : "mac",
       "scale" : "1x",
       "size" : "256x256"
     },
     {
+      "filename" : "icon_256x256@2x.png",
       "idiom" : "mac",
       "scale" : "2x",
       "size" : "256x256"
     },
     {
+      "filename" : "icon_512x512.png",
       "idiom" : "mac",
       "scale" : "1x",
       "size" : "512x512"
     },
     {
+      "filename" : "icon_512x512@2x.png",
       "idiom" : "mac",
       "scale" : "2x",
       "size" : "512x512"
diff --git a/Map/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/Map/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
new file mode 100644 (file)
index 0000000..b4618d4
Binary files /dev/null and b/Map/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ
diff --git a/Map/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/Map/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
new file mode 100644 (file)
index 0000000..dfdf396
Binary files /dev/null and b/Map/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ
diff --git a/Map/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/Map/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
new file mode 100644 (file)
index 0000000..fb22da2
Binary files /dev/null and b/Map/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ
diff --git a/Map/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/Map/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
new file mode 100644 (file)
index 0000000..a2ea184
Binary files /dev/null and b/Map/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ
diff --git a/Map/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/Map/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
new file mode 100644 (file)
index 0000000..cbe1cfc
Binary files /dev/null and b/Map/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ
diff --git a/Map/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/Map/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
new file mode 100644 (file)
index 0000000..64bc6b5
Binary files /dev/null and b/Map/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ
diff --git a/Map/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/Map/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
new file mode 100644 (file)
index 0000000..a2ea184
Binary files /dev/null and b/Map/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ
diff --git a/Map/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/Map/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
new file mode 100644 (file)
index 0000000..5663b5e
Binary files /dev/null and b/Map/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ
diff --git a/Map/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/Map/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
new file mode 100644 (file)
index 0000000..64bc6b5
Binary files /dev/null and b/Map/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ
diff --git a/Map/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/Map/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
new file mode 100644 (file)
index 0000000..bfa4365
Binary files /dev/null and b/Map/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ
diff --git a/Map/ContentView.swift b/Map/ContentView.swift
deleted file mode 100644 (file)
index ae5b6cd..0000000
+++ /dev/null
@@ -1,97 +0,0 @@
-//
-//  ContentView.swift
-//  Map
-//
-//  Created by Ruben Beltran del Rio on 2/1/21.
-//
-
-import SwiftUI
-import CoreData
-
-struct ContentView: View {
-    @Environment(\.managedObjectContext) private var viewContext
-
-    @FetchRequest(
-        sortDescriptors: [NSSortDescriptor(keyPath: \Map.createdAt, ascending: true)],
-        animation: .default)
-    private var maps: FetchedResults<Map>
-
-    var body: some View {
-        NavigationView {
-        List {
-            ForEach(maps) { map in
-                NavigationLink(destination: MapDetailView(map: map)) {
-                HStack {
-                    Text(map.title ?? "Untitled Map")
-                    Text(map.createdAt!.format())
-                        .font(.footnote)
-                        .foregroundColor(Color.accentColor)
-                }
-                }
-            }
-            .onDelete(perform: deleteMaps)
-        }
-        .toolbar {
-            HStack {
-                Button(action: toggleSidebar) {
-                    Label("Toggle Sidebar", systemImage: "sidebar.left")
-                }
-                Button(action: addMap) {
-                    Label("Add Map", systemImage: "plus")
-                }
-            }
-        }
-        }
-    }
-    
-    private func toggleSidebar() {
-            NSApp.keyWindow?.firstResponder?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)
-    }
-
-    private func addMap() {
-        withAnimation {
-            let newMap = Map(context: viewContext)
-            newMap.uuid = UUID()
-            newMap.createdAt = Date()
-            newMap.title = "Map \(newMap.createdAt!.format())"
-            newMap.content = ""
-
-            do {
-                try viewContext.save()
-            } catch {
-                // Replace this implementation with code to handle the error appropriately.
-                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
-                let nsError = error as NSError
-                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
-            }
-        }
-    }
-
-    private func deleteMaps(offsets: IndexSet) {
-        withAnimation {
-            offsets.map { maps[$0] }.forEach(viewContext.delete)
-
-            do {
-                try viewContext.save()
-            } catch {
-                // Replace this implementation with code to handle the error appropriately.
-                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
-                let nsError = error as NSError
-                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
-            }
-        }
-    }
-}
-
-private let mapFormatter: DateFormatter = {
-    let formatter = DateFormatter()
-    formatter.dateStyle = .short
-    formatter.timeStyle = .medium
-    return formatter
-}()
-
-struct ContentView_Previews: PreviewProvider {
-    static var previews: some View {
-        ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
-    }
-}
diff --git a/Map/Extensions/Binding+unwrap.swift b/Map/Extensions/Binding+unwrap.swift
deleted file mode 100644 (file)
index 3498cc3..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-import SwiftUI
-
-extension Binding {
-    init(_ source: Binding<Value?>, _ defaultValue: Value) {
-        // Ensure a non-nil value in `source`.
-        if source.wrappedValue == nil {
-            source.wrappedValue = defaultValue
-        }
-        // Unsafe unwrap because *we* know it's non-nil now.
-        self.init(source)!
-    }
-}
index c12e67d7bf06e2d5673018863344440d088e7278..d29ed0f0fc20a617722c1a17534cc8aae7403246 100644 (file)
@@ -8,10 +8,10 @@
 import Foundation
 
 extension Date {
-    func format() -> String {
-        let formatter = DateFormatter()
-        formatter.dateStyle = .short
-        formatter.timeStyle = .medium
-        return formatter.string(from: self)
-    }
+  func format() -> String {
+    let formatter = DateFormatter()
+    formatter.dateStyle = .short
+    formatter.timeStyle = .medium
+    return formatter.string(from: self)
+  }
 }
index 03e799330bcadeeb833be57beabf317af77514b8..d82ba99173f534750026aacbb4f01362d00a84f2 100644 (file)
 import CoreGraphics
 import Foundation
 
-let VERTEX_PATTERN = "([^\\(]+?)[\\s]*\\([\\s]*([0-9]+.?[0-9]*)[\\s]*,[\\s]*([0-9]+.?[0-9]*)[\\s]*\\)"
-let EDGE_PATTERN = "(.+?)[\\s]*->[\\s]*(.+)"
+let vertexPattern =
+  "([^\\(]+?)[\\s]*\\([\\s]*([0-9]+.?[0-9]*)[\\s]*,[\\s]*([0-9]+.?[0-9]*)[\\s]*\\)"
+let edgePattern = "(.+?)[\\s]*-([->])[\\s]*(.+)"
+let blockerPattern = "\\[Blocker\\][\\s]*(.+)"
+let opportunityPattern = "\\[Opportunity\\][\\s]*(.+)[\\s]+([-+])[\\s]*([0-9]+.?[0-9]*)"
+let stagePattern = "\\[(I{1,3})\\][\\s]*([0-9]+.?[0-9]*)"
 
 struct ParsedMap {
-    let vertices: [Vertex]
-    let edges: [MapEdge]
+  let vertices: [Vertex]
+  let edges: [MapEdge]
+  let blockers: [Blocker]
+  let opportunities: [Opportunity]
+  let stages: [CGFloat]
 }
 
 struct Vertex {
-    let id: Int
-    let label: String
-    let position: CGPoint
+  let id: Int
+  let label: String
+  let position: CGPoint
 }
 
 struct MapEdge {
-    let id: Int
-    let origin: CGPoint
-    let destination: CGPoint
+  let id: Int
+  let origin: CGPoint
+  let destination: CGPoint
+  let arrowhead: Bool
 }
 
+struct Blocker {
+  let id: Int
+  let position: CGPoint
+}
+
+struct Opportunity {
+  let id: Int
+  let origin: CGPoint
+  let destination: CGPoint
+}
+
+let defaultDimensions: [CGFloat] = [
+  25.0,
+  50.0,
+  75.0,
+]
+
 // Extracts the vertices from the text
 
 func parseVertices(_ text: String) -> [String: CGPoint] {
-    
-    var result: [String: CGPoint] = [:]
-    let regex = try! NSRegularExpression(pattern: VERTEX_PATTERN, options: .caseInsensitive)
-    
-    let lines = text.split(whereSeparator: \.isNewline)
-    
-    for line in lines {
-        let range = NSRange(location: 0, length: line.utf16.count)
-        let matches = regex.matches(in: String(line), options: [], range: range)
-        
-        if matches.count > 0 && matches[0].numberOfRanges == 4{
-
-            let match = matches[0];
-            let key = String(line[Range(match.range(at: 1), in: line)!])
-            let xString = String(line[Range(match.range(at: 2), in: line)!])
-            let yString = String(line[Range(match.range(at: 3), in: line)!])
-            let x = CGFloat(truncating: NumberFormatter().number(from:xString) ?? 0.0)
-            let y = CGFloat(truncating: NumberFormatter().number(from:yString) ?? 0.0)
-            let point = CGPoint(x: x, y: y)
-            
-            result[key] = point
-        }
+
+  var result: [String: CGPoint] = [:]
+  let regex = try! NSRegularExpression(pattern: vertexPattern, options: .caseInsensitive)
+
+  let lines = text.split(whereSeparator: \.isNewline)
+
+  for line in lines {
+    let range = NSRange(location: 0, length: line.utf16.count)
+    let matches = regex.matches(in: String(line), options: [], range: range)
+
+    if matches.count > 0 && matches[0].numberOfRanges == 4 {
+
+      let match = matches[0]
+      let key = String(line[Range(match.range(at: 1), in: line)!])
+      let xString = String(line[Range(match.range(at: 2), in: line)!])
+      let yString = String(line[Range(match.range(at: 3), in: line)!])
+      let x = CGFloat(truncating: NumberFormatter().number(from: xString) ?? 0.0)
+      let y = CGFloat(truncating: NumberFormatter().number(from: yString) ?? 0.0)
+      let point = CGPoint(x: x, y: y)
+
+      result[key] = point
     }
-    
-    return result
-}
+  }
 
-// Extracts the edges from the text
+  return result
+}
 
 func parseEdges(_ text: String, vertices: [String: CGPoint]) -> [MapEdge] {
-    
-    var result: [MapEdge] = []
-    let regex = try! NSRegularExpression(pattern: EDGE_PATTERN, options: .caseInsensitive)
-    
-    let lines = text.split(whereSeparator: \.isNewline)
-    
-    for (index, line) in lines.enumerated() {
-        let range = NSRange(location: 0, length: line.utf16.count)
-        let matches = regex.matches(in: String(line), options: [], range: range)
-        
-        if matches.count > 0 && matches[0].numberOfRanges == 3 {
-
-            let match = matches[0];
-            let vertexA = String(line[Range(match.range(at: 1), in: line)!])
-            let vertexB = String(line[Range(match.range(at: 2), in: line)!])
-            
-            if let origin = vertices[vertexA] {
-                if let destination = vertices[vertexB] {
-                    result.append(MapEdge(id: index, origin: origin, destination: destination))
-                }
-            }
+
+  var result: [MapEdge] = []
+  let regex = try! NSRegularExpression(pattern: edgePattern, options: .caseInsensitive)
+
+  let lines = text.split(whereSeparator: \.isNewline)
+
+  for (index, line) in lines.enumerated() {
+    let range = NSRange(location: 0, length: line.utf16.count)
+    let matches = regex.matches(in: String(line), options: [], range: range)
+
+    if matches.count > 0 && matches[0].numberOfRanges == 4 {
+
+      let match = matches[0]
+      let arrowhead = String(line[Range(match.range(at: 2), in: line)!]) == ">"
+      let vertexA = String(line[Range(match.range(at: 1), in: line)!])
+      let vertexB = String(line[Range(match.range(at: 3), in: line)!])
+
+      if let origin = vertices[vertexA] {
+        if let destination = vertices[vertexB] {
+          result.append(
+            MapEdge(id: index, origin: origin, destination: destination, arrowhead: arrowhead))
         }
+      }
     }
-    
-    return result
+  }
+
+  return result
+}
+
+func parseOpportunities(_ text: String, vertices: [String: CGPoint]) -> [Opportunity] {
+
+  var result: [Opportunity] = []
+  let regex = try! NSRegularExpression(pattern: opportunityPattern, options: .caseInsensitive)
+
+  let lines = text.split(whereSeparator: \.isNewline)
+
+  for (index, line) in lines.enumerated() {
+    let range = NSRange(location: 0, length: line.utf16.count)
+    let matches = regex.matches(in: String(line), options: [], range: range)
+
+    if matches.count > 0 && matches[0].numberOfRanges == 4 {
+
+      let match = matches[0]
+      let multiplier = CGFloat(
+        String(line[Range(match.range(at: 2), in: line)!]) == "-" ? -1.0 : 1.0)
+      let vertex = String(line[Range(match.range(at: 1), in: line)!])
+      let opportunityString = String(line[Range(match.range(at: 3), in: line)!])
+      let opportunity = CGFloat(
+        truncating: NumberFormatter().number(from: opportunityString) ?? 0.0)
+
+      if let origin = vertices[vertex] {
+        let destination = CGPoint(x: origin.x + opportunity * multiplier, y: origin.y)
+        result.append(Opportunity(id: index, origin: origin, destination: destination))
+      }
+    }
+  }
+
+  return result
+}
+
+func parseBlockers(_ text: String, vertices: [String: CGPoint]) -> [Blocker] {
+
+  var result: [Blocker] = []
+  let regex = try! NSRegularExpression(pattern: blockerPattern, options: .caseInsensitive)
+
+  let lines = text.split(whereSeparator: \.isNewline)
+
+  for (index, line) in lines.enumerated() {
+    let range = NSRange(location: 0, length: line.utf16.count)
+    let matches = regex.matches(in: String(line), options: [], range: range)
+
+    if matches.count > 0 && matches[0].numberOfRanges == 2 {
+
+      let match = matches[0]
+      let vertexA = String(line[Range(match.range(at: 1), in: line)!])
+
+      if let position = vertices[vertexA] {
+        result.append(Blocker(id: index, position: position))
+      }
+    }
+  }
+
+  return result
+}
+
+func parseStages(_ text: String) -> [CGFloat] {
+
+  var result = defaultDimensions
+  let regex = try! NSRegularExpression(pattern: stagePattern, options: .caseInsensitive)
+
+  let lines = text.split(whereSeparator: \.isNewline)
+
+  for line in lines {
+    let range = NSRange(location: 0, length: line.utf16.count)
+    let matches = regex.matches(in: String(line), options: [], range: range)
+
+    if matches.count > 0 && matches[0].numberOfRanges == 3 {
+
+      let match = matches[0]
+      let stage = String(line[Range(match.range(at: 1), in: line)!])
+      let dimensionsString = String(line[Range(match.range(at: 2), in: line)!])
+      let dimensions = CGFloat(truncating: NumberFormatter().number(from: dimensionsString) ?? 0.0)
+
+      result[stage.count - 1] = dimensions
+    }
+  }
+
+  return result
 }
 
 // Converts vetex dictionary to array
 
 func mapVertices(_ vertices: [String: CGPoint]) -> [Vertex] {
-    var i = 0
-    return vertices.map { label, position in
-        i += 1;
-        return Vertex(id: i, label: label, position: position)
-    }
+  var i = 0
+  return vertices.map { label, position in
+    i += 1
+    return Vertex(id: i, label: label, position: position)
+  }
 }
 
 extension Map {
-    func parse() -> ParsedMap {
-        
-        let text = self.content ?? ""
-        let vertices = parseVertices(text)
-        let mappedVertices = mapVertices(vertices)
-        let edges = parseEdges(text, vertices: vertices)
-        print(mappedVertices)
-        print(edges)
-        
-        return ParsedMap(vertices: mappedVertices, edges: edges)
-    }
+  func parse() -> ParsedMap {
+
+    let text = self.content ?? ""
+    let vertices = parseVertices(text)
+    let mappedVertices = mapVertices(vertices)
+    let edges = parseEdges(text, vertices: vertices)
+    let blockers = parseBlockers(text, vertices: vertices)
+    let opportunities = parseOpportunities(text, vertices: vertices)
+    let stages = parseStages(text)
+
+    return ParsedMap(
+      vertices: mappedVertices, edges: edges, blockers: blockers, opportunities: opportunities,
+      stages: stages)
+  }
 }
diff --git a/Map/Extensions/NSImage+writePNG.swift b/Map/Extensions/NSImage+writePNG.swift
new file mode 100644 (file)
index 0000000..c24c0cf
--- /dev/null
@@ -0,0 +1,25 @@
+import Cocoa
+
+extension NSImage {
+  public func writePNG(toURL url: URL) {
+
+    guard let data = tiffRepresentation,
+      let rep = NSBitmapImageRep(data: data),
+      let imgData = rep.representation(
+        using: .png, properties: [.compressionFactor: NSNumber(floatLiteral: 1.0)])
+    else {
+
+      print(
+        "\(self.self) Error Function '\(#function)' Line: \(#line) No tiff rep found for image writing to \(url)"
+      )
+      return
+    }
+
+    do {
+      try imgData.write(to: url)
+    } catch let error {
+      print(
+        "\(self.self) Error Function '\(#function)' Line: \(#line) \(error.localizedDescription)")
+    }
+  }
+}
index 69c84ae74d799ac86b737655867a55a07165554c..ca5178635e04d2a2d79ed4fb4bd85e1f3b5958c9 100644 (file)
@@ -15,7 +15,7 @@
        <key>CFBundlePackageType</key>
        <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
        <key>CFBundleShortVersionString</key>
-       <string>1.0</string>
+       <string>$(MARKETING_VERSION)</string>
        <key>CFBundleVersion</key>
        <string>1</string>
        <key>LSMinimumSystemVersion</key>
index f2ef3ae0265b40c475e8ef90e3a311c31786c594..6060301e73053c3a7737aba76441b166361f9a95 100644 (file)
@@ -2,9 +2,19 @@
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <plist version="1.0">
 <dict>
-    <key>com.apple.security.app-sandbox</key>
-    <true/>
-    <key>com.apple.security.files.user-selected.read-only</key>
-    <true/>
+       <key>com.apple.developer.aps-environment</key>
+       <string>development</string>
+       <key>com.apple.developer.icloud-container-identifiers</key>
+       <array>
+               <string>iCloud.pizza.unlimited.map</string>
+       </array>
+       <key>com.apple.developer.icloud-services</key>
+       <array>
+               <string>CloudKit</string>
+       </array>
+       <key>com.apple.security.app-sandbox</key>
+       <true/>
+       <key>com.apple.security.files.user-selected.read-write</key>
+       <true/>
 </dict>
 </plist>
diff --git a/Map/MapApp.swift b/Map/MapApp.swift
deleted file mode 100644 (file)
index 10b6bcb..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-//
-//  MapApp.swift
-//  Map
-//
-//  Created by Ruben Beltran del Rio on 2/1/21.
-//
-
-import SwiftUI
-
-@main
-struct MapApp: App {
-    let persistenceController = PersistenceController.shared
-
-    var body: some Scene {
-        WindowGroup {
-            ContentView()
-                .environment(\.managedObjectContext, persistenceController.container.viewContext)
-        }
-    }
-}
diff --git a/Map/MapDetail.swift b/Map/MapDetail.swift
deleted file mode 100644 (file)
index 74e3c8d..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-//
-//  ContentView.swift
-//  Map
-//
-//  Created by Ruben Beltran del Rio on 2/1/21.
-//
-
-import SwiftUI
-import CoreData
-
-struct MapDetailView: View {
-    @Environment(\.managedObjectContext) private var viewContext
-    
-    @ObservedObject var map: Map
-    
-    @State private var selectedEvolution = StageType.General
-
-    var body: some View {
-        VSplitView {
-        VStack {
-            TextField("Title", text: Binding($map.title, ""), onCommit:  {
-                try? viewContext.save()
-            })
-            Picker("Evolution", selection: $selectedEvolution) {
-                ForEach(StageType.allCases) { stage in
-                    Text(Stage.title(stage)).tag(stage)
-                }
-            }
-            TextEditor(text: Binding($map.content, "")).onChange(of: map.content) { _ in
-                try? viewContext.save()
-            }.font(Font.system(size: 16, design: .monospaced))
-        }
-            ScrollView([.horizontal, .vertical]) {
-                MapRenderView(map: map, evolution: Stage.stages(selectedEvolution))
-            }
-        }
-    }
-}
-
-struct MapDetailView_Previews: PreviewProvider {
-    static var previews: some View {
-        MapDetailView(map: Map()).environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
-    }
-}
diff --git a/Map/MapRender.swift b/Map/MapRender.swift
deleted file mode 100644 (file)
index 79d80b5..0000000
+++ /dev/null
@@ -1,125 +0,0 @@
-//
-//  ContentView.swift
-//  Map
-//
-//  Created by Ruben Beltran del Rio on 2/1/21.
-//
-
-import SwiftUI
-import CoreData
-import CoreGraphics
-
-struct MapRenderView: View {
-    @ObservedObject var map: Map
-    let evolution: Stage
-    
-    let VERTEX_SIZE = CGSize(width: 25.0, height: 25.0)
-    let ARROWHEAD_SIZE = CGFloat(10.0)
-    let LINE_WIDTH = CGFloat(1.0)
-    let PADDING = CGFloat(4.0)
-    let STAGE_FRAME = CGSize(width: 245, height: 50)
-    
-    var parsedMap: ParsedMap {
-        return map.parse()
-    }
-
-    var body: some View {
-        ZStack(alignment: .topLeading) {
-            ZStack (alignment: .topLeading) {
-            // The Axes
-            Path { path in
-                path.move(to: CGPoint(x: 0, y: 0))
-                path.addLine(to: CGPoint(x: 0, y: 1000))
-                path.addLine(to: CGPoint(x: 1000, y: 1000))
-                path.move(to: CGPoint(x: 1000, y: 1000))
-                path.closeSubpath()
-                
-            }.strokedPath(StrokeStyle(lineWidth: LINE_WIDTH * 2))
-            Text("Visible").font(.title3).rotationEffect(Angle(degrees: -90.0)).offset(CGSize(width: -35.0, height: 0.0))
-            Text("Value Chain").font(.title).rotationEffect(Angle(degrees: -90.0)).offset(CGSize(width: -72.0, height: 480.0))
-            Text("Invisible").font(.title3).rotationEffect(Angle(degrees: -90.0)).offset(CGSize(width: -40.0, height: 980.0))
-                Text(evolution.I).font(.title3).frame(width: STAGE_FRAME.width, height: STAGE_FRAME.height, alignment: .topLeading).offset(CGSize(width: 0.0, height: 1000.0))
-                Text(evolution.II).font(.title3).frame(width: STAGE_FRAME.width, height: STAGE_FRAME.height, alignment: .topLeading).offset(CGSize(width: 250.0, height: 1000.0))
-            Text(evolution.III).font(.title3).frame(width: STAGE_FRAME.width, height: STAGE_FRAME.height, alignment: .topLeading).offset(CGSize(width: 500.0, height: 1000.0))
-            Text(evolution.IV).font(.title3).frame(width: STAGE_FRAME.width, height: STAGE_FRAME.height, alignment: .topLeading).offset(CGSize(width: 750.0, height: 1000.0))
-            }.offset(CGSize(width: 0.0, height: 0.0))
-            
-            // The Lanes
-            Path { path in
-                path.move(to: CGPoint(x: 250, y: 0))
-                path.addLine(to: CGPoint(x: 250, y: 1000))
-                path.move(to: CGPoint(x: 500, y: 0))
-                path.addLine(to: CGPoint(x: 500, y: 1000))
-                path.move(to: CGPoint(x: 750, y: 0))
-                path.addLine(to: CGPoint(x: 750, y: 1000))
-                path.move(to: CGPoint(x: 250, y: 0))
-                path.closeSubpath()
-            }.strokedPath(StrokeStyle(lineWidth: LINE_WIDTH, dash: [10.0]))
-            
-            Path { path in
-                path.addRect(CGRect(x: 0, y: 0, width: 250, height: 1000))
-            }.fill(Color.red).opacity(0.1)
-            Path { path in
-                path.addRect(CGRect(x: 250, y: 0, width: 250, height: 1000))
-            }.fill(Color.orange).opacity(0.1)
-            Path { path in
-                path.addRect(CGRect(x: 500, y: 0, width: 250, height: 1000))
-            }.fill(Color.yellow).opacity(0.1)
-            Path { path in
-                path.addRect(CGRect(x: 750, y: 0, width: 250, height: 1000))
-            }.fill(Color.green).opacity(0.1)
-            
-            // The Vertices
-            
-            ForEach(parsedMap.vertices, id: \.id) { vertex in
-                Path { path in
-                        path.addEllipse(in: CGRect(
-                            origin: vertex.position, size: VERTEX_SIZE
-                        ))
-                }
-                Text(vertex.label).foregroundColor(Color.gray).offset(CGSize(
-                                        width: vertex.position.x + VERTEX_SIZE.width + PADDING,
-                                        height: vertex.position.y))
-            }
-            
-            // The Edges
-            
-            ForEach(parsedMap.edges, id: \.id) { edge in
-                Path { path in
-                    
-                    let slope = (edge.destination.y - edge.origin.y) / (edge.destination.x - edge.origin.x)
-                    let angle = atan(slope)
-                    let multiplier = CGFloat(slope < 0 ? -1.0 : 1.0)
-                    let upperAngle = angle - CGFloat.pi / 4.0
-                    let lowerAngle = angle + CGFloat.pi / 4.0
-                    
-                    let offsetOrigin = CGPoint(x: edge.origin.x + multiplier * (VERTEX_SIZE.width / 2.0) * cos(angle), y: edge.origin.y + multiplier * (VERTEX_SIZE.height / 2.0) * sin(angle))
-                    let offsetDestination = CGPoint(x: edge.destination.x - multiplier * (VERTEX_SIZE.width / 2.0) * cos(angle), y: edge.destination.y - multiplier * (VERTEX_SIZE.height / 2.0) * sin(angle))
-                    
-                        path.move(to: offsetOrigin)
-                        path.addLine(to: offsetDestination)
-                    
-                    // Arrowheads
-                        path.move(to: offsetDestination)
-                    path.addLine(to: CGPoint(x: offsetDestination.x - multiplier * ARROWHEAD_SIZE * cos(upperAngle), y:
-                                                offsetDestination.y - multiplier * ARROWHEAD_SIZE * sin(upperAngle)))
-                    
-                    path.move(to: offsetDestination)
-                    path.addLine(to: CGPoint(x: offsetDestination.x - multiplier * ARROWHEAD_SIZE * cos(lowerAngle), y:
-                                                offsetDestination.y - multiplier *  ARROWHEAD_SIZE * sin(lowerAngle)))
-                    path.move(to: offsetDestination)
-                        path.closeSubpath()
-                }.applying(CGAffineTransform(translationX: VERTEX_SIZE.width / 2.0, y: VERTEX_SIZE.height / 2.0)).strokedPath(StrokeStyle(lineWidth: LINE_WIDTH))
-            }
-        }.frame(
-            minWidth: 1050.0, maxWidth: .infinity,
-            minHeight: 1050.0, maxHeight: .infinity, alignment: .topLeading
-        ).padding(30.0)
-    }
-}
-
-struct MapRenderView_Previews: PreviewProvider {
-    static var previews: some View {
-        MapDetailView(map: Map()).environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
-    }
-}
diff --git a/Map/MapRenderComponents/MapAxes.swift b/Map/MapRenderComponents/MapAxes.swift
new file mode 100644 (file)
index 0000000..e7d65f4
--- /dev/null
@@ -0,0 +1,89 @@
+import SwiftUI
+
+struct MapAxes: View {
+
+  @Environment(\.colorScheme) var colorScheme
+
+  let mapSize: CGSize
+  let lineWidth: CGFloat
+  let evolution: Stage
+  let stages: [CGFloat]
+  let stageHeight = CGFloat(50.0)
+  let padding = CGFloat(5.0)
+
+  var color: Color {
+    MapColor.colorForScheme(colorScheme).foreground
+  }
+
+  var body: some View {
+    ZStack(alignment: .topLeading) {
+
+      // Axis Lines
+      Path { path in
+        path.move(to: CGPoint(x: 0, y: 0))
+        path.addLine(to: CGPoint(x: 0, y: mapSize.height))
+        path.addLine(to: CGPoint(x: mapSize.width, y: mapSize.height))
+        path.move(to: CGPoint(x: mapSize.width, y: mapSize.height))
+        path.closeSubpath()
+      }.stroke(color, lineWidth: lineWidth * 2)
+
+      // Y Labels
+      Text("Visible").font(.title3).foregroundColor(color).rotationEffect(Angle(degrees: -90.0))
+        .offset(CGSize(width: -35.0, height: 0.0))
+      Text("Value Chain").font(.title).foregroundColor(color).rotationEffect(Angle(degrees: -90.0))
+        .offset(CGSize(width: -72.0, height: mapSize.height / 2 - 20))
+      Text("Invisible").font(.title3).foregroundColor(color).rotationEffect(Angle(degrees: -90.0))
+        .offset(CGSize(width: -40.0, height: mapSize.height - 20))
+
+      // X Labels
+
+      Text("Uncharted")
+        .font(.title3)
+        .foregroundColor(color)
+        .frame(width: mapSize.width / 4, height: stageHeight, alignment: .topLeading)
+        .offset(CGSize(width: 0.0, height: -stageHeight / 2.0))
+      Text("Industrialised")
+        .font(.title3)
+        .foregroundColor(color)
+        .frame(width: mapSize.width / 4, height: stageHeight, alignment: .topLeading)
+        .offset(CGSize(width: mapSize.width - 100.0, height: -stageHeight / 2.0))
+
+      Text(evolution.i)
+        .font(.title3)
+        .foregroundColor(color)
+        .frame(width: w(stages[0]), height: stageHeight, alignment: .topLeading)
+        .offset(CGSize(width: 0.0, height: mapSize.height + padding))
+
+      Text(evolution.ii)
+        .font(.title3)
+        .foregroundColor(color)
+        .frame(width: w(stages[1]) - w(stages[0]), height: stageHeight, alignment: .topLeading)
+        .offset(CGSize(width: w(stages[0]), height: mapSize.height + padding))
+
+      Text(evolution.iii)
+        .font(.title3)
+        .foregroundColor(color)
+        .frame(width: w(stages[2]) - w(stages[1]), height: stageHeight, alignment: .topLeading)
+        .offset(CGSize(width: w(stages[1]), height: mapSize.height + padding))
+
+      Text(evolution.iv)
+        .font(.title3)
+        .foregroundColor(color)
+        .frame(width: mapSize.width - w(stages[2]), height: stageHeight, alignment: .topLeading)
+        .offset(CGSize(width: w(stages[2]), height: mapSize.height + padding))
+    }
+  }
+
+  func w(_ dimension: CGFloat) -> CGFloat {
+    max(0.0, min(mapSize.width, dimension * mapSize.width / 100.0))
+  }
+}
+
+struct MapAxes_Previews: PreviewProvider {
+  static var previews: some View {
+    MapAxes(
+      mapSize: CGSize(width: 200.0, height: 200.0), lineWidth: CGFloat(1.0),
+      evolution: Stage.stages(.general), stages: [25.0, 50.0, 75.0]
+    ).padding(50.0)
+  }
+}
diff --git a/Map/MapRenderComponents/MapBlockers.swift b/Map/MapRenderComponents/MapBlockers.swift
new file mode 100644 (file)
index 0000000..b668b30
--- /dev/null
@@ -0,0 +1,49 @@
+import SwiftUI
+
+struct MapBlockers: View {
+
+  @Environment(\.colorScheme) var colorScheme
+
+  let mapSize: CGSize
+  let vertexSize: CGSize
+  let blockers: [Blocker]
+
+  var color: Color {
+    MapColor.colorForScheme(colorScheme).blocker
+  }
+
+  let cornerSize = CGSize(width: 2.0, height: 2.0)
+
+  var body: some View {
+    ForEach(blockers, id: \.id) { vertex in
+      Path { path in
+        path.addRoundedRect(
+          in: CGRect(
+            origin: CGPoint(
+              x: w(vertex.position.x) + 3 * vertexSize.width,
+              y: h(vertex.position.y) - vertexSize.height * 2 / 3),
+            size: CGSize(width: vertexSize.width / 2, height: vertexSize.height * 2)
+          ), cornerSize: cornerSize)
+      }.fill(color)
+    }
+  }
+
+  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))
+  }
+}
+
+struct MapBlockers_Previews: PreviewProvider {
+  static var previews: some View {
+    MapBlockers(
+      mapSize: CGSize(width: 400.0, height: 400.0), vertexSize: CGSize(width: 25.0, height: 25.0),
+      blockers: [
+        Blocker(id: 0, position: CGPoint(x: 50.0, y: 50.0)),
+        Blocker(id: 1, position: CGPoint(x: 10.0, y: 20.0)),
+      ])
+  }
+}
diff --git a/Map/MapRenderComponents/MapColor.swift b/Map/MapRenderComponents/MapColor.swift
new file mode 100644 (file)
index 0000000..ce3d453
--- /dev/null
@@ -0,0 +1,45 @@
+import SwiftUI
+
+struct MapColor {
+  let foreground: Color
+  let background: Color
+  let secondary: Color
+  let blocker: Color
+  let opportunity: Color
+  let stages: StageColor
+
+  static func colorForScheme(_ colorScheme: ColorScheme) -> MapColor {
+    if colorScheme == .dark {
+      return MapColor(
+        foreground: Color.white,
+        background: Color(.sRGB, red: 0.13, green: 0.13, blue: 0.13),
+        secondary: Color(.sRGB, red: 0.81, green: 0.78, blue: 0.79),
+        blocker: Color(.sRGB, red: 0.13, green: 0.13, blue: 0.13),
+        opportunity: Color(.sRGB, red: 1.0, green: 0.37, blue: 0.34),
+        stages: StageColor(
+          i: Color(.sRGB, red: 0.37, green: 0.16, blue: 0.25),
+          ii: Color(.sRGB, red: 0.30, green: 0.29, blue: 0.26),
+          iii: Color(.sRGB, red: 0.15, green: 0.29, blue: 0.23),
+          iv: Color(.sRGB, red: 0.14, green: 0.22, blue: 0.31)))
+    } else {
+      return MapColor(
+        foreground: Color(.sRGB, red: 0.13, green: 0.13, blue: 0.13),
+        background: Color.white,
+        secondary: Color.gray,
+        blocker: Color(.sRGB, red: 0.60, green: 0.52, blue: 0.51),
+        opportunity: Color(.sRGB, red: 1.0, green: 0.37, blue: 0.34),
+        stages: StageColor(
+          i: Color(.sRGB, red: 1.00, green: 0.93, blue: 0.97),
+          ii: Color(.sRGB, red: 1.00, green: 0.98, blue: 0.92),
+          iii: Color(.sRGB, red: 0.93, green: 1.00, blue: 0.97),
+          iv: Color(.sRGB, red: 0.93, green: 0.96, blue: 1.00)))
+    }
+  }
+}
+
+struct StageColor {
+  let i: Color
+  let ii: Color
+  let iii: Color
+  let iv: Color
+}
diff --git a/Map/MapRenderComponents/MapEdges.swift b/Map/MapRenderComponents/MapEdges.swift
new file mode 100644 (file)
index 0000000..d40d2aa
--- /dev/null
@@ -0,0 +1,92 @@
+//
+//  MapEdges.swift
+//  Map
+//
+//  Created by Ruben Beltran del Rio on 2/2/21.
+//
+
+import SwiftUI
+
+struct MapEdges: View {
+
+  @Environment(\.colorScheme) var colorScheme
+
+  let mapSize: CGSize
+  let lineWidth: CGFloat
+  let vertexSize: CGSize
+  let edges: [MapEdge]
+
+  let arrowheadSize = CGFloat(10.0)
+
+  var color: Color {
+    MapColor.colorForScheme(colorScheme).foreground
+  }
+
+  var body: some View {
+    ForEach(edges, id: \.id) { edge in
+      Path { path in
+
+        // First we transform edges from percentage to map coordinates
+        let origin = CGPoint(x: w(edge.origin.x), y: h(edge.origin.y))
+        let destination = CGPoint(x: w(edge.destination.x), y: h(edge.destination.y))
+
+        let slope = (destination.y - origin.y) / (destination.x - origin.x)
+        let angle = atan(slope)
+        let multiplier = CGFloat(slope < 0 ? -1.0 : 1.0)
+        let upperAngle = angle - CGFloat.pi / 4.0
+        let lowerAngle = angle + CGFloat.pi / 4.0
+
+        let offsetOrigin = CGPoint(
+          x: origin.x + multiplier * (vertexSize.width / 2.0) * cos(angle),
+          y: origin.y + multiplier * (vertexSize.height / 2.0) * sin(angle))
+        let offsetDestination = CGPoint(
+          x: destination.x - multiplier * (vertexSize.width / 2.0) * cos(angle),
+          y: destination.y - multiplier * (vertexSize.height / 2.0) * sin(angle))
+
+        path.move(to: offsetOrigin)
+        path.addLine(to: offsetDestination)
+
+        if edge.arrowhead {
+          path.move(to: offsetDestination)
+          path.addLine(
+            to: CGPoint(
+              x: offsetDestination.x - multiplier * arrowheadSize * cos(upperAngle),
+              y:
+                offsetDestination.y - multiplier * arrowheadSize * sin(upperAngle)))
+
+          path.move(to: offsetDestination)
+          path.addLine(
+            to: CGPoint(
+              x: offsetDestination.x - multiplier * arrowheadSize * cos(lowerAngle),
+              y:
+                offsetDestination.y - multiplier * arrowheadSize * sin(lowerAngle)))
+        }
+        path.move(to: offsetDestination)
+        path.closeSubpath()
+      }.applying(
+        CGAffineTransform(translationX: vertexSize.width / 2.0, y: vertexSize.height / 2.0)
+      ).stroke(color, lineWidth: lineWidth)
+    }
+  }
+
+  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))
+  }
+}
+
+struct MapEdges_Previews: PreviewProvider {
+  static var previews: some View {
+    MapEdges(
+      mapSize: CGSize(width: 400.0, height: 400.0), lineWidth: 1.0,
+      vertexSize: CGSize(width: 25.0, height: 25.0),
+      edges: [
+        MapEdge(
+          id: 1, origin: CGPoint(x: 2.0, y: 34.0), destination: CGPoint(x: 23.0, y: 76.2),
+          arrowhead: true)
+      ])
+  }
+}
diff --git a/Map/MapRenderComponents/MapOpportunities.swift b/Map/MapRenderComponents/MapOpportunities.swift
new file mode 100644 (file)
index 0000000..68d3fb5
--- /dev/null
@@ -0,0 +1,84 @@
+//
+//  MapEdges.swift
+//  Map
+//
+//  Created by Ruben Beltran del Rio on 2/2/21.
+//
+
+import SwiftUI
+
+struct MapOpportunities: View {
+
+  @Environment(\.colorScheme) var colorScheme
+
+  let mapSize: CGSize
+  let lineWidth: CGFloat
+  let vertexSize: CGSize
+  let opportunities: [Opportunity]
+
+  let arrowheadSize = CGFloat(10.0)
+
+  var color: Color {
+    MapColor.colorForScheme(colorScheme).opportunity
+  }
+
+  var body: some View {
+    ForEach(opportunities, id: \.id) { edge in
+      Path { path in
+
+        // First we transform edges from percentage to map coordinates
+        let origin = CGPoint(x: w(edge.origin.x), y: h(edge.origin.y))
+        let destination = CGPoint(x: w(edge.destination.x), y: h(edge.destination.y))
+
+        let multiplier = CGFloat(edge.destination.x > edge.origin.x ? 1.0 : -1.0)
+        let upperAngle = -CGFloat.pi / 4.0
+        let lowerAngle = CGFloat.pi / 4.0
+
+        let offsetOrigin = CGPoint(x: origin.x + multiplier * (vertexSize.width / 2.0), y: origin.y)
+        let offsetDestination = CGPoint(
+          x: destination.x - multiplier * (vertexSize.width / 2.0), y: destination.y)
+
+        path.move(to: offsetOrigin)
+        path.addLine(to: offsetDestination)
+
+        path.move(to: offsetDestination)
+        path.addLine(
+          to: CGPoint(
+            x: offsetDestination.x - multiplier * arrowheadSize * cos(upperAngle),
+            y:
+              offsetDestination.y - multiplier * arrowheadSize * sin(upperAngle)))
+
+        path.move(to: offsetDestination)
+        path.addLine(
+          to: CGPoint(
+            x: offsetDestination.x - multiplier * arrowheadSize * cos(lowerAngle),
+            y:
+              offsetDestination.y - multiplier * arrowheadSize * sin(lowerAngle)))
+
+        path.move(to: offsetDestination)
+        path.closeSubpath()
+      }.applying(
+        CGAffineTransform(translationX: vertexSize.width / 2.0, y: vertexSize.height / 2.0)
+      ).strokedPath(StrokeStyle(lineWidth: lineWidth * 2, dash: [10.0])).stroke(color)
+    }
+  }
+
+  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))
+  }
+}
+
+struct MapOpportunities_Previews: PreviewProvider {
+  static var previews: some View {
+    MapOpportunities(
+      mapSize: CGSize(width: 400.0, height: 400.0), lineWidth: 1.0,
+      vertexSize: CGSize(width: 25.0, height: 25.0),
+      opportunities: [
+        Opportunity(id: 1, origin: CGPoint(x: 2.0, y: 34.0), destination: CGPoint(x: 23.0, y: 76.2))
+      ])
+  }
+}
diff --git a/Map/MapRenderComponents/MapStages.swift b/Map/MapRenderComponents/MapStages.swift
new file mode 100644 (file)
index 0000000..052a315
--- /dev/null
@@ -0,0 +1,60 @@
+import SwiftUI
+
+struct MapStages: View {
+
+  @Environment(\.colorScheme) var colorScheme
+
+  let mapSize: CGSize
+  let lineWidth: CGFloat
+  let stages: [CGFloat]
+  let opacity = 0.1
+
+  var color: MapColor {
+    MapColor.colorForScheme(colorScheme)
+  }
+
+  var body: some View {
+
+    ZStack(alignment: .topLeading) {
+      Path { path in
+        path.addRect(CGRect(x: 0, y: 0, width: w(stages[0]), height: mapSize.height))
+      }.fill(color.stages.i)
+      Path { path in
+        path.addRect(
+          CGRect(x: w(stages[0]), y: 0, width: w(stages[1]) - w(stages[0]), height: mapSize.height))
+      }.fill(color.stages.ii)
+      Path { path in
+        path.addRect(
+          CGRect(x: w(stages[1]), y: 0, width: w(stages[2]) - w(stages[1]), height: mapSize.height))
+      }.fill(color.stages.iii)
+      Path { path in
+        path.addRect(
+          CGRect(x: w(stages[2]), y: 0, width: mapSize.width - w(stages[2]), height: mapSize.height)
+        )
+      }.fill(color.stages.iv)
+
+      Path { path in
+        path.move(to: CGPoint(x: w(stages[0]), y: 0))
+        path.addLine(to: CGPoint(x: w(stages[0]), y: mapSize.height))
+        path.move(to: CGPoint(x: w(stages[1]), y: 0))
+        path.addLine(to: CGPoint(x: w(stages[1]), y: mapSize.height))
+        path.move(to: CGPoint(x: w(stages[2]), y: 0))
+        path.addLine(to: CGPoint(x: w(stages[2]), y: mapSize.height))
+        path.move(to: CGPoint(x: w(stages[0]), y: 0))
+        path.closeSubpath()
+      }.strokedPath(StrokeStyle(lineWidth: lineWidth, dash: [10.0])).stroke(color.foreground)
+    }
+  }
+
+  func w(_ dimension: CGFloat) -> CGFloat {
+    max(0.0, min(mapSize.width, dimension * mapSize.width / 100.0))
+  }
+}
+
+struct MapStages_Previews: PreviewProvider {
+  static var previews: some View {
+    MapStages(
+      mapSize: CGSize(width: 200.0, height: 200.0), lineWidth: CGFloat(1.0),
+      stages: [25.0, 50.0, 75.0])
+  }
+}
diff --git a/Map/MapRenderComponents/MapVertices.swift b/Map/MapRenderComponents/MapVertices.swift
new file mode 100644 (file)
index 0000000..71f85be
--- /dev/null
@@ -0,0 +1,49 @@
+import SwiftUI
+
+struct MapVertices: View {
+
+  @Environment(\.colorScheme) var colorScheme
+
+  let mapSize: CGSize
+  let vertexSize: CGSize
+  let vertices: [Vertex]
+  let padding = CGFloat(4.0)
+
+  var color: MapColor {
+    MapColor.colorForScheme(colorScheme)
+  }
+
+  var body: some View {
+    ForEach(vertices, id: \.id) { vertex in
+      Path { path in
+        path.addEllipse(
+          in: CGRect(
+            origin: CGPoint(x: w(vertex.position.x), y: h(vertex.position.y)), size: vertexSize
+          ))
+      }.fill(color.foreground)
+      Text(vertex.label).foregroundColor(color.secondary).offset(
+        CGSize(
+          width: w(vertex.position.x) + vertexSize.width + padding,
+          height: h(vertex.position.y)))
+    }
+  }
+
+  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))
+  }
+}
+
+struct MapVertices_Previews: PreviewProvider {
+  static var previews: some View {
+    MapVertices(
+      mapSize: CGSize(width: 400.0, height: 400.0), vertexSize: CGSize(width: 25.0, height: 25.0),
+      vertices: [
+        Vertex(id: 0, label: "A", position: CGPoint(x: 50.0, y: 50.0)),
+        Vertex(id: 0, label: "A", position: CGPoint(x: 10.0, y: 20.0)),
+      ])
+  }
+}
diff --git a/Map/Persistence.swift b/Map/Persistence.swift
deleted file mode 100644 (file)
index 49bb2d9..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-//
-//  Persistence.swift
-//  Map
-//
-//  Created by Ruben Beltran del Rio on 2/1/21.
-//
-
-import CoreData
-
-struct PersistenceController {
-    static let shared = PersistenceController()
-
-    static var preview: PersistenceController = {
-        let result = PersistenceController(inMemory: true)
-        let viewContext = result.container.viewContext
-        for _ in 0..<10 {
-            let newMap = Map(context: viewContext)
-            newMap.uuid = UUID()
-            newMap.createdAt = Date()
-            newMap.title = "Map \(newMap.createdAt!.format())"
-            newMap.content = ""
-        }
-        do {
-            try viewContext.save()
-        } catch {
-            // Replace this implementation with code to handle the error appropriately.
-            // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
-            let nsError = error as NSError
-            fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
-        }
-        return result
-    }()
-
-    let container: NSPersistentCloudKitContainer
-
-    init(inMemory: Bool = false) {
-        container = NSPersistentCloudKitContainer(name: "Map")
-        if inMemory {
-            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
-        }
-        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
-            if let error = error as NSError? {
-                // Replace this implementation with code to handle the error appropriately.
-                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
-
-                /*
-                Typical reasons for an error here include:
-                * The parent directory does not exist, cannot be created, or disallows writing.
-                * The persistent store is not accessible, due to permissions or data protection when the device is locked.
-                * The device is out of space.
-                * The store could not be migrated to the current model version.
-                Check the error message to determine what the actual problem was.
-                */
-                fatalError("Unresolved error \(error), \(error.userInfo)")
-            }
-        })
-    }
-}
diff --git a/Map/Stage.swift b/Map/Stage.swift
deleted file mode 100644 (file)
index 4a53ada..0000000
+++ /dev/null
@@ -1,108 +0,0 @@
-struct Stage {
-    let I: String
-    let II: String
-    let III: String
-    let IV: String
-    
-    static func stages(_ type: StageType) -> Stage {
-        switch(type) {
-        case .General:
-            return Stage(I: "Genesis", II: "Custom Built", III: "Product (+rental)", IV: "Commodity (+utility)")
-        case .Ubiquity:
-            return Stage(I: "Rare", II: "Slowly Increasing Consumption", III: "Rapidly Increasing Consumption", IV: "Widespread and stabilising")
-        case .Certainty:
-            return Stage(I: "Poorly Understood", II: "Rapid Increase In Learning", III: "Rapid Increase in Use / fit for purpose", IV: "Commonly understood (in terms of use)")
-        case .PublicationTypes:
-            return Stage(I: "Normally describing the wonder of the thing", II: "Build / construct / awareness and learning", III: "Maintenance / operations / installation / feature", IV: "Focused on use")
-        case .Market:
-            return Stage(I: "Undefined Market", II: "Forming Market", III: "Growing Market", IV: "Mature Market")
-        case .KnowledgeManagement:
-            return Stage(I: "Uncertain", II: "Learning on use", III: "Learning on operation", IV: "Known / accepted")
-        case .MarketPerception:
-            return Stage(I: "Chaotic (non-linear)", II: "Domain of experts", III: "Increasing expectation of use", IV: "Ordered (appearance of being trivial) / trivial")
-        case .UserPerception:
-            return Stage(I: "Different / confusing / exciting / surprising", II: "Leading edge / emerging", III: "Increasingly common / disappointed if not used", IV: "Standard / expected")
-        case .PerceptionInIndustry:
-            return Stage(I: "Competitive advantage / unpredictable / unknown", II: "Competitive advantage / ROI / case examples", III: "Advantage through implementation / features", IV: "Cost of doing business")
-        case .FocusOfValue:
-            return Stage(I: "High future worth", II: "Seeking profit / ROI", III: "High profitability", IV: "High volume / reducing margin")
-        case .Understanding:
-            return Stage(I: "Poorly Understood / unpredictable", II: "Increasing understanding / development of measures", III: "Increasing education / constant refinement of needs / measures", IV: "Believed to be well defined / stable / measurable")
-        case .Comparison:
-            return Stage(I: "Constantly changing / a differential / unstable", II: "Learning from others / testing the water / some evidential support", III: "Feature difference", IV: "Essential / operational advantage")
-        case .Failure:
-            return Stage(I: "High / tolerated / assumed", II: "Moderate / unsurprising but disappointed", III: "Not tolerated, focus on constant improvement", IV: "Operational efficiency and surprised by failure")
-        case .MarketAction:
-            return Stage(I: "Gambling / driven by gut", II: "Exploring a \"found\" value", III: "Market analysis / listening to customers", IV: "Metric driven / build what is needed")
-        case .Efficiency:
-            return Stage(I: "Reducing the cost of change (experimentation)", II: "Reducing cost of waste (Learning)", III: "Reducing cost of waste (Learning)", IV: "Reducing cost of deviation (Volume)")
-        case .DecisionDrivers:
-            return Stage(I: "Heritage / culture", II: "Analyses & synthesis", III: "Analyses & synthesis", IV: "Previous Experience")
-        case .Behavior:
-            return Stage(I: "Needs to be pushed", II: "Shown in simple situations", III: "Shown in moderately complex situations ", IV: "Always shown")
-        }
-    }
-    
-    static func title(_ type: StageType) -> String {
-        switch(type) {
-        case .General:
-            return "General"
-        case .Ubiquity:
-            return "Ubiquity"
-        case .Certainty:
-            return "Certainty"
-        case .PublicationTypes:
-            return "Publication Types"
-        case .Market:
-            return "Market"
-        case .KnowledgeManagement:
-            return "Knowledge Management"
-        case .MarketPerception:
-            return "Market Perception"
-        case .UserPerception:
-            return "User Perception"
-        case .PerceptionInIndustry:
-            return "Perception In Industry"
-        case .FocusOfValue:
-            return "Focus Of Value"
-        case .Understanding:
-            return "Understanding"
-        case .Comparison:
-            return "Comparison"
-        case .Failure:
-            return "Failure"
-        case .MarketAction:
-            return "Market Action"
-        case .Efficiency:
-            return "Efficiency"
-        case .DecisionDrivers:
-            return "Decision Drivers"
-        case .Behavior:
-            return "Behavior"
-        }
-    }
-}
-
-enum StageType: String, CaseIterable, Identifiable {
-    case General
-    case Ubiquity
-    case Certainty
-    case PublicationTypes
-    case Market
-    case KnowledgeManagement
-    case MarketPerception
-    case UserPerception
-    case PerceptionInIndustry
-    case FocusOfValue
-    case Understanding
-    case Comparison
-    case Failure
-    case MarketAction
-    case Efficiency
-    case DecisionDrivers
-    case Behavior
-    
-    var id: String { self.rawValue }
-}
-
-
diff --git a/Map/State/AppState.swift b/Map/State/AppState.swift
new file mode 100644 (file)
index 0000000..e10efea
--- /dev/null
@@ -0,0 +1,103 @@
+import Cocoa
+import Foundation
+import SwiftUI
+
+struct AppState {
+  var selectedMap: Map? = nil
+  var mapBeingDeleted: Map? = nil
+}
+
+enum AppAction {
+  case selectMap(map: Map?)
+  case deleteMap(map: Map)
+  case exportMapAsImage(map: Map, evolution: StageType)
+  case exportMapAsText(map: Map)
+}
+
+func appStateReducer(state: inout AppState, action: AppAction) {
+
+  switch action {
+
+  case .selectMap(let map):
+    state.selectedMap = map
+
+  case .deleteMap(let map):
+    let context = PersistenceController.shared.container.viewContext
+
+    context.delete(map)
+    try? context.save()
+
+  case .exportMapAsImage(let map, let evolution):
+    let window = NSWindow(
+      contentRect: .init(
+        origin: .zero,
+        size: .init(
+          width: NSScreen.main!.frame.width,
+          height: NSScreen.main!.frame.height)),
+      styleMask: [.closable],
+      backing: .buffered,
+      defer: false)
+
+    window.title = map.title ?? "Untitled Map"
+    window.isOpaque = true
+    window.center()
+    window.isMovableByWindowBackground = true
+    window.makeKeyAndOrderFront(nil)
+
+    let renderView = MapRenderView(map: map, evolution: Stage.stages(evolution))
+
+    let view = NSHostingView(rootView: renderView)
+    window.contentView = view
+
+    let imageRepresentation = view.bitmapImageRepForCachingDisplay(in: view.bounds)!
+    view.cacheDisplay(in: view.bounds, to: imageRepresentation)
+    let image = NSImage(cgImage: imageRepresentation.cgImage!, size: view.bounds.size)
+
+    let dialog = NSSavePanel()
+
+    dialog.title = "Save Map"
+    dialog.showsResizeIndicator = false
+    dialog.canCreateDirectories = true
+    dialog.showsHiddenFiles = false
+    dialog.allowedFileTypes = ["png"]
+    dialog.nameFieldStringValue = map.title ?? "Untitled Map"
+
+    if dialog.runModal() == NSApplication.ModalResponse.OK {
+      let result = dialog.url
+
+      if result != nil {
+
+        image.writePNG(toURL: result!)
+        print("saved at \(result!)")
+      }
+    } else {
+      print("Cancel")
+    }
+    window.orderOut(nil)
+
+  case .exportMapAsText(let map):
+    let dialog = NSSavePanel()
+
+    dialog.title = "Save Map Text"
+    dialog.showsResizeIndicator = false
+    dialog.canCreateDirectories = true
+    dialog.showsHiddenFiles = false
+    dialog.allowedFileTypes = ["txt"]
+    dialog.nameFieldStringValue = map.title ?? "Untitled Map"
+
+    if let content = map.content {
+
+      if dialog.runModal() == NSApplication.ModalResponse.OK {
+        let result = dialog.url
+
+        if let result = result {
+          try? content.write(to: result, atomically: true, encoding: String.Encoding.utf8)
+        }
+      } else {
+        print("Cancel")
+      }
+    }
+  }
+}
+
+typealias AppStore = Store<AppState, AppAction>
diff --git a/Map/State/Persistence.swift b/Map/State/Persistence.swift
new file mode 100644 (file)
index 0000000..1eb09e8
--- /dev/null
@@ -0,0 +1,42 @@
+import CoreData
+
+struct PersistenceController {
+  static let shared = PersistenceController()
+
+  static var preview: PersistenceController = {
+    let result = PersistenceController(inMemory: true)
+    let viewContext = result.container.viewContext
+    for _ in 0..<10 {
+      let newMap = Map(context: viewContext)
+      newMap.uuid = UUID()
+      newMap.createdAt = Date()
+      newMap.title = "Map \(newMap.createdAt!.format())"
+      newMap.content = ""
+    }
+    do {
+      try viewContext.save()
+    } catch {
+      let nsError = error as NSError
+      fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
+    }
+    return result
+  }()
+
+  let container: NSPersistentCloudKitContainer
+
+  init(inMemory: Bool = false) {
+    container = NSPersistentCloudKitContainer(name: "Map")
+
+    container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
+    container.viewContext.automaticallyMergesChangesFromParent = true
+
+    if inMemory {
+      container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
+    }
+    container.loadPersistentStores(completionHandler: { (storeDescription, error) in
+      if let error = error as NSError? {
+        fatalError("Unresolved error \(error), \(error.userInfo)")
+      }
+    })
+  }
+}
diff --git a/Map/State/Store.swift b/Map/State/Store.swift
new file mode 100644 (file)
index 0000000..7860f33
--- /dev/null
@@ -0,0 +1,18 @@
+import Foundation
+
+final class Store<State, Action>: ObservableObject {
+  @Published private(set) var state: State
+
+  private let reducer: Reducer<State, Action>
+
+  init(initialState: State, reducer: @escaping Reducer<State, Action>) {
+    self.state = initialState
+    self.reducer = reducer
+  }
+
+  func send(_ action: Action) {
+    reducer(&state, action)
+  }
+}
+
+typealias Reducer<State, Action> = (inout State, Action) -> Void
diff --git a/Map/Views/ContentView.swift b/Map/Views/ContentView.swift
new file mode 100644 (file)
index 0000000..4f55c27
--- /dev/null
@@ -0,0 +1,104 @@
+//
+//  ContentView.swift
+//  Map
+//
+//  Created by Ruben Beltran del Rio on 2/1/21.
+//
+
+import CoreData
+import SwiftUI
+
+struct ContentView: View {
+  @Environment(\.managedObjectContext) private var viewContext
+
+  @FetchRequest(
+    sortDescriptors: [NSSortDescriptor(keyPath: \Map.createdAt, ascending: true)],
+    animation: .default)
+  private var maps: FetchedResults<Map>
+
+  var body: some View {
+    NavigationView {
+      List {
+        if maps.count == 0 {
+          DefaultMapView()
+        }
+        ForEach(maps) { map in
+          NavigationLink(destination: MapDetailView(map: map)) {
+            HStack {
+              Text(map.title ?? "Untitled Map")
+              Spacer()
+              Text(mapFormatter.string(from: (map.createdAt ?? Date())))
+                .font(.caption)
+                .padding(.vertical, 2.0)
+                .padding(.horizontal, 4.0)
+                .background(Color.accentColor)
+                .foregroundColor(Color.black)
+                .cornerRadius(2.0)
+            }.padding(.leading, 8.0)
+          }
+        }
+        .onDelete(perform: deleteMaps)
+      }.frame(minWidth: 250.0, alignment: .leading)
+        .toolbar {
+          HStack {
+            Button(action: toggleSidebar) {
+              Label("Toggle Sidebar", systemImage: "sidebar.left")
+            }
+            Button(action: addMap) {
+              Label("Add Map", systemImage: "plus")
+            }
+          }
+        }
+    }
+  }
+
+  private func toggleSidebar() {
+    NSApp.keyWindow?.firstResponder?.tryToPerform(
+      #selector(NSSplitViewController.toggleSidebar(_:)), with: nil)
+  }
+
+  private func addMap() {
+    withAnimation {
+      let newMap = Map(context: viewContext)
+      newMap.uuid = UUID()
+      newMap.createdAt = Date()
+      newMap.title = "Map \(newMap.createdAt!.format())"
+      newMap.content = ""
+
+      do {
+        try viewContext.save()
+      } catch {
+        let nsError = error as NSError
+        fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
+      }
+    }
+  }
+
+  private func deleteMaps(offsets: IndexSet) {
+
+    withAnimation {
+      offsets.map { maps[$0] }.forEach(viewContext.delete)
+
+      do {
+        try viewContext.save()
+      } catch {
+        let nsError = error as NSError
+        fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
+      }
+    }
+  }
+}
+
+private let mapFormatter: DateFormatter = {
+  let formatter = DateFormatter()
+  formatter.dateStyle = .short
+  formatter.timeStyle = .none
+  return formatter
+}()
+
+struct ContentView_Previews: PreviewProvider {
+  static var previews: some View {
+    ContentView().environment(
+      \.managedObjectContext, PersistenceController.preview.container.viewContext)
+  }
+}
diff --git a/Map/Views/DefaultMapView.swift b/Map/Views/DefaultMapView.swift
new file mode 100644 (file)
index 0000000..66914f1
--- /dev/null
@@ -0,0 +1,23 @@
+//
+//  ContentView.swift
+//  Map
+//
+//  Created by Ruben Beltran del Rio on 2/1/21.
+//
+
+import CoreData
+import SwiftUI
+
+struct DefaultMapView: View {
+
+  var body: some View {
+    Text("Select a map from the left hand side, or click on + to create one.")
+  }
+}
+
+struct DefaultMapView_Previews: PreviewProvider {
+  static var previews: some View {
+    MapDetailView(map: Map()).environment(
+      \.managedObjectContext, PersistenceController.preview.container.viewContext)
+  }
+}
diff --git a/Map/Views/MapApp.swift b/Map/Views/MapApp.swift
new file mode 100644 (file)
index 0000000..0f8ae64
--- /dev/null
@@ -0,0 +1,21 @@
+//
+//  MapApp.swift
+//  Map
+//
+//  Created by Ruben Beltran del Rio on 2/1/21.
+//
+
+import SwiftUI
+
+@main
+struct MapApp: App {
+  let persistenceController = PersistenceController.shared
+
+  var body: some Scene {
+    WindowGroup {
+      ContentView()
+        .environment(\.managedObjectContext, persistenceController.container.viewContext)
+        .environmentObject(AppStore(initialState: AppState(), reducer: appStateReducer))
+    }.windowStyle(HiddenTitleBarWindowStyle())
+  }
+}
diff --git a/Map/Views/MapDetail.swift b/Map/Views/MapDetail.swift
new file mode 100644 (file)
index 0000000..a8ea3d1
--- /dev/null
@@ -0,0 +1,101 @@
+//
+//  ContentView.swift
+//  Map
+//
+//  Created by Ruben Beltran del Rio on 2/1/21.
+//
+
+import CoreData
+import SwiftUI
+
+struct MapDetailView: View {
+  @Environment(\.managedObjectContext) private var viewContext
+  @Environment(\.colorScheme) var colorScheme
+
+  @EnvironmentObject var store: AppStore
+
+  @ObservedObject var map: Map
+
+  private var mapColor: MapColor {
+    MapColor.colorForScheme(colorScheme)
+  }
+
+  private var title: Binding<String> {
+    Binding(
+      get: { map.title ?? "" },
+      set: { title in
+        map.title = title
+      }
+    )
+  }
+
+  private var content: Binding<String> {
+    Binding(
+      get: { map.content ?? "" },
+      set: { content in
+        map.content = content
+      }
+    )
+  }
+
+  @State private var selectedEvolution = StageType.general
+
+  var body: some View {
+    if map.uuid != nil {
+      VSplitView {
+        VStack {
+          HStack {
+            TextField(
+              "Title", text: title,
+              onCommit: {
+                try? viewContext.save()
+              }
+            ).font(.title2).textFieldStyle(PlainTextFieldStyle()).padding(.vertical, 4.0).padding(
+              .leading, 4.0)
+            Button(action: saveText) {
+              Image(systemName: "doc.text")
+            }.padding(.vertical, 4.0).padding(.leading, 4.0)
+            Button(action: saveImage) {
+              Image(systemName: "photo")
+            }.padding(.vertical, 4.0).padding(.leading, 4.0).padding(.trailing, 8.0)
+          }
+          Picker("Evolution", selection: $selectedEvolution) {
+            ForEach(StageType.allCases) { stage in
+              Text(Stage.title(stage)).tag(stage).padding(4.0)
+            }
+          }.padding(.horizontal, 8.0).padding(.vertical, 4.0)
+
+          ZStack(alignment: .topLeading) {
+            MapTextEditor(text: content).onChange(of: map.content) { _ in
+              try? viewContext.save()
+            }
+            .background(mapColor.background)
+            .foregroundColor(mapColor.foreground)
+            .frame(minHeight: 96.0)
+          }.padding(.top, 8.0).padding(.leading, 8.0).background(mapColor.background).cornerRadius(
+            5.0)
+        }.padding(.horizontal, 8.0)
+        ScrollView([.horizontal, .vertical]) {
+          MapRenderView(map: map, evolution: Stage.stages(selectedEvolution))
+        }.background(mapColor.background)
+      }
+    } else {
+      DefaultMapView()
+    }
+  }
+
+  private func saveText() {
+    store.send(.exportMapAsText(map: map))
+  }
+
+  private func saveImage() {
+    store.send(.exportMapAsImage(map: map, evolution: selectedEvolution))
+  }
+}
+
+struct MapDetailView_Previews: PreviewProvider {
+  static var previews: some View {
+    MapDetailView(map: Map()).environment(
+      \.managedObjectContext, PersistenceController.preview.container.viewContext)
+  }
+}
diff --git a/Map/Views/MapRender.swift b/Map/Views/MapRender.swift
new file mode 100644 (file)
index 0000000..b35b724
--- /dev/null
@@ -0,0 +1,61 @@
+//
+//  ContentView.swift
+//  Map
+//
+//  Created by Ruben Beltran del Rio on 2/1/21.
+//
+
+import CoreData
+import CoreGraphics
+import SwiftUI
+
+struct MapRenderView: View {
+
+  @Environment(\.colorScheme) var colorScheme
+
+  @ObservedObject var map: Map
+  let evolution: Stage
+
+  let mapSize = CGSize(width: 1300.0, height: 1000.0)
+
+  let lineWidth = CGFloat(1.0)
+  let vertexSize = CGSize(width: 25.0, height: 25.0)
+  let padding = CGFloat(30.0)
+
+  var parsedMap: ParsedMap {
+    return map.parse()
+  }
+
+  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 * 2))
+      }.fill(MapColor.colorForScheme(colorScheme).background)
+
+      MapStages(mapSize: mapSize, lineWidth: lineWidth, stages: parsedMap.stages)
+      MapAxes(
+        mapSize: mapSize, lineWidth: lineWidth, evolution: evolution, stages: parsedMap.stages)
+      MapOpportunities(
+        mapSize: mapSize, lineWidth: lineWidth, vertexSize: vertexSize,
+        opportunities: parsedMap.opportunities)
+      MapBlockers(mapSize: mapSize, vertexSize: vertexSize, blockers: parsedMap.blockers)
+      MapVertices(mapSize: mapSize, vertexSize: vertexSize, vertices: parsedMap.vertices)
+      MapEdges(
+        mapSize: mapSize, lineWidth: lineWidth, vertexSize: vertexSize, edges: parsedMap.edges)
+    }.frame(
+      width: mapSize.width,
+      height: mapSize.height, alignment: .topLeading
+    ).padding(padding)
+  }
+}
+
+struct MapRenderView_Previews: PreviewProvider {
+  static var previews: some View {
+    MapDetailView(map: Map()).environment(
+      \.managedObjectContext, PersistenceController.preview.container.viewContext)
+  }
+}
diff --git a/Map/Views/MapTextEditor.swift b/Map/Views/MapTextEditor.swift
new file mode 100644 (file)
index 0000000..7251c59
--- /dev/null
@@ -0,0 +1,71 @@
+import Cocoa
+import SwiftUI
+
+class MapTextEditorController: NSViewController {
+
+  @Binding var text: String
+
+  init(text: Binding<String>) {
+    self._text = text
+    super.init(nibName: nil, bundle: nil)
+  }
+
+  required init?(coder: NSCoder) {
+    fatalError("init(coder:) has not been implemented")
+  }
+
+  override func loadView() {
+    let scrollView = NSTextView.scrollableTextView()
+    let textView = scrollView.documentView as! NSTextView
+
+    scrollView.translatesAutoresizingMaskIntoConstraints = false
+
+    textView.delegate = self
+    textView.string = self.text
+    textView.isEditable = true
+    textView.font = .monospacedSystemFont(ofSize: 16.0, weight: .regular)
+    self.view = scrollView
+  }
+
+  override func viewDidAppear() {
+    self.view.window?.makeFirstResponder(self.view)
+  }
+}
+
+extension MapTextEditorController: NSTextViewDelegate {
+
+  func textDidChange(_ obj: Notification) {
+    if let textField = obj.object as? NSTextView {
+      self.text = textField.string
+    }
+  }
+
+  func textView(_ view: NSTextView, shouldChangeTextIn: NSRange, replacementString: String?) -> Bool
+  {
+    let range = Range(shouldChangeTextIn, in: view.string)
+    let target = view.string[range!]
+
+    if target == "--" {
+      return false
+    }
+
+    return true
+  }
+}
+
+struct MapTextEditor: NSViewControllerRepresentable {
+
+  @Binding var text: String
+
+  func makeNSViewController(
+    context: NSViewControllerRepresentableContext<MapTextEditor>
+  ) -> MapTextEditorController {
+    return MapTextEditorController(text: $text)
+  }
+
+  func updateNSViewController(
+    _ nsViewController: MapTextEditorController,
+    context: NSViewControllerRepresentableContext<MapTextEditor>
+  ) {
+  }
+}
diff --git a/Map/Views/Stage.swift b/Map/Views/Stage.swift
new file mode 100644 (file)
index 0000000..23432ad
--- /dev/null
@@ -0,0 +1,161 @@
+struct Stage {
+  let i: String
+  let ii: String
+  let iii: String
+  let iv: String
+
+  static func stages(_ type: StageType) -> Stage {
+    switch type {
+    case .general:
+      return Stage(
+        i: "Genesis", ii: "Custom Built", iii: "Product (+rental)", iv: "Commodity (+utility)")
+    case .practice:
+      return Stage(
+        i: "Novel", ii: "Emerging", iii: "Good", iv: "Best")
+    case .data:
+      return Stage(
+        i: "Unmodelled", ii: "Divergent", iii: "Convergent", iv: "Modelled")
+    case .knowledge:
+      return Stage(
+        i: "Concept", ii: "Hypothesis", iii: "Theory", iv: "Accepted")
+    case .ubiquity:
+      return Stage(
+        i: "Rare", ii: "Slowly Increasing Consumption", iii: "Rapidly Increasing Consumption",
+        iv: "Widespread and stabilising")
+    case .certainty:
+      return Stage(
+        i: "Poorly Understood", ii: "Rapid Increase In Learning",
+        iii: "Rapid Increase in Use / fit for purpose", iv: "Commonly understood (in terms of use)")
+    case .publicationTypes:
+      return Stage(
+        i: "Normally describing the wonder of the thing",
+        ii: "Build / construct / awareness and learning",
+        iii: "Maintenance / operations / installation / feature", iv: "Focused on use")
+    case .market:
+      return Stage(
+        i: "Undefined Market", ii: "Forming Market", iii: "Growing Market", iv: "Mature Market")
+    case .knowledgeManagement:
+      return Stage(
+        i: "Uncertain", ii: "Learning on use", iii: "Learning on operation", iv: "Known / accepted")
+    case .marketPerception:
+      return Stage(
+        i: "Chaotic (non-linear)", ii: "Domain of experts", iii: "Increasing expectation of use",
+        iv: "Ordered (appearance of being trivial) / trivial")
+    case .userPerception:
+      return Stage(
+        i: "Different / confusing / exciting / surprising", ii: "Leading edge / emerging",
+        iii: "Increasingly common / disappointed if not used", iv: "Standard / expected")
+    case .perceptionInIndustry:
+      return Stage(
+        i: "Competitive advantage / unpredictable / unknown",
+        ii: "Competitive advantage / ROI / case examples",
+        iii: "Advantage through implementation / features", iv: "Cost of doing business")
+    case .focusOfValue:
+      return Stage(
+        i: "High future worth", ii: "Seeking profit / ROI", iii: "High profitability",
+        iv: "High volume / reducing margin")
+    case .understanding:
+      return Stage(
+        i: "Poorly Understood / unpredictable",
+        ii: "Increasing understanding / development of measures",
+        iii: "Increasing education / constant refinement of needs / measures",
+        iv: "Believed to be well defined / stable / measurable")
+    case .comparison:
+      return Stage(
+        i: "Constantly changing / a differential / unstable",
+        ii: "Learning from others / testing the water / some evidential support",
+        iii: "Feature difference", iv: "Essential / operational advantage")
+    case .failure:
+      return Stage(
+        i: "High / tolerated / assumed", ii: "Moderate / unsurprising but disappointed",
+        iii: "Not tolerated, focus on constant improvement",
+        iv: "Operational efficiency and surprised by failure")
+    case .marketAction:
+      return Stage(
+        i: "Gambling / driven by gut", ii: "Exploring a \"found\" value",
+        iii: "Market analysis / listening to customers", iv: "Metric driven / build what is needed")
+    case .efficiency:
+      return Stage(
+        i: "Reducing the cost of change (experimentation)", ii: "Reducing cost of waste (Learning)",
+        iii: "Reducing cost of waste (Learning)", iv: "Reducing cost of deviation (Volume)")
+    case .decisionDrivers:
+      return Stage(
+        i: "Heritage / culture", ii: "Analyses & synthesis", iii: "Analyses & synthesis",
+        iv: "Previous Experience")
+    case .behavior:
+      return Stage(
+        i: "Uncertain when to use", ii: "Learning when to use", iii: "Learning through use",
+        iv: "Known / common usage")
+    }
+  }
+
+  static func title(_ type: StageType) -> String {
+    switch type {
+    case .general:
+      return "Activities"
+    case .practice:
+      return "Practice"
+    case .data:
+      return "Data"
+    case .knowledge:
+      return "Knowledge"
+    case .ubiquity:
+      return "Ubiquity"
+    case .certainty:
+      return "Certainty"
+    case .publicationTypes:
+      return "Publication Types"
+    case .market:
+      return "Market"
+    case .knowledgeManagement:
+      return "Knowledge Management"
+    case .marketPerception:
+      return "Market Perception"
+    case .userPerception:
+      return "User Perception"
+    case .perceptionInIndustry:
+      return "Perception In Industry"
+    case .focusOfValue:
+      return "Focus Of Value"
+    case .understanding:
+      return "Understanding"
+    case .comparison:
+      return "Comparison"
+    case .failure:
+      return "Failure"
+    case .marketAction:
+      return "Market Action"
+    case .efficiency:
+      return "Efficiency"
+    case .decisionDrivers:
+      return "Decision Drivers"
+    case .behavior:
+      return "Behavior"
+    }
+  }
+}
+
+enum StageType: String, CaseIterable, Identifiable {
+  case general
+  case practice
+  case data
+  case knowledge
+  case ubiquity
+  case certainty
+  case publicationTypes
+  case market
+  case knowledgeManagement
+  case marketPerception
+  case userPerception
+  case perceptionInIndustry
+  case focusOfValue
+  case understanding
+  case comparison
+  case failure
+  case marketAction
+  case efficiency
+  case decisionDrivers
+  case behavior
+
+  var id: String { self.rawValue }
+}
index 4f2a183c259bfd43c7c06c53246f60ab33267ac6..e425c00460dcf0b81fd4402da091e4d62359fe9c 100644 (file)
@@ -6,28 +6,29 @@
 //
 
 import XCTest
+
 @testable import Map
 
 class MapTests: XCTestCase {
 
-    override func setUpWithError() throws {
-        // Put setup code here. This method is called before the invocation of each test method in the class.
-    }
+  override func setUpWithError() throws {
+    // Put setup code here. This method is called before the invocation of each test method in the class.
+  }
 
-    override func tearDownWithError() throws {
-        // Put teardown code here. This method is called after the invocation of each test method in the class.
-    }
+  override func tearDownWithError() throws {
+    // Put teardown code here. This method is called after the invocation of each test method in the class.
+  }
 
-    func testExample() throws {
-        // This is an example of a functional test case.
-        // Use XCTAssert and related functions to verify your tests produce the correct results.
-    }
+  func testExample() throws {
+    // This is an example of a functional test case.
+    // Use XCTAssert and related functions to verify your tests produce the correct results.
+  }
 
-    func testPerformanceExample() throws {
-        // This is an example of a performance test case.
-        self.measure {
-            // Put the code you want to measure the time of here.
-        }
+  func testPerformanceExample() throws {
+    // This is an example of a performance test case.
+    self.measure {
+      // Put the code you want to measure the time of here.
     }
+  }
 
 }
index ee1eeca5edb8bd3a1cce8bb868feda1cd87212e0..d2d523d4ccd8ccc2cb30e7be5dedebd4a260a108 100644 (file)
@@ -9,34 +9,34 @@ import XCTest
 
 class MapUITests: XCTestCase {
 
-    override func setUpWithError() throws {
-        // Put setup code here. This method is called before the invocation of each test method in the class.
-
-        // In UI tests it is usually best to stop immediately when a failure occurs.
-        continueAfterFailure = false
-
-        // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
-    }
-
-    override func tearDownWithError() throws {
-        // Put teardown code here. This method is called after the invocation of each test method in the class.
-    }
-
-    func testExample() throws {
-        // UI tests must launch the application that they test.
-        let app = XCUIApplication()
-        app.launch()
-
-        // Use recording to get started writing UI tests.
-        // Use XCTAssert and related functions to verify your tests produce the correct results.
-    }
-
-    func testLaunchPerformance() throws {
-        if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) {
-            // This measures how long it takes to launch your application.
-            measure(metrics: [XCTApplicationLaunchMetric()]) {
-                XCUIApplication().launch()
-            }
-        }
+  override func setUpWithError() throws {
+    // Put setup code here. This method is called before the invocation of each test method in the class.
+
+    // In UI tests it is usually best to stop immediately when a failure occurs.
+    continueAfterFailure = false
+
+    // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
+  }
+
+  override func tearDownWithError() throws {
+    // Put teardown code here. This method is called after the invocation of each test method in the class.
+  }
+
+  func testExample() throws {
+    // UI tests must launch the application that they test.
+    let app = XCUIApplication()
+    app.launch()
+
+    // Use recording to get started writing UI tests.
+    // Use XCTAssert and related functions to verify your tests produce the correct results.
+  }
+
+  func testLaunchPerformance() throws {
+    if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) {
+      // This measures how long it takes to launch your application.
+      measure(metrics: [XCTApplicationLaunchMetric()]) {
+        XCUIApplication().launch()
+      }
     }
+  }
 }
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..9c8daf1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,66 @@
+# Map
+
+A wardley mapping tool: Write some text, get a diagram
+
+## Building
+
+Open the project in Xcode and press buithe run button
+
+## Formatting and Linting
+
+In order to format and lint the code, the project provides a Makefile that
+uses [swift-format][swift-format].
+
+* To format: `make format`
+* To lint: `make lint`
+
+These commands run on every `.swift` file in the directory.
+
+## Language Reference
+
+### Nodes
+
+Nodes should be of the format `Name (x,y)`. The name can contain spaces, and
+the `x`/`y` can be integers or decimals. All dimensions go from 0 - 100, so
+50 means 50% of the way through. eg.
+
+- `Node (1,2)`
+- `My Cool Node (1.0,2.0)`
+- `A (1, 2.0)`
+
+### Edges
+
+Edges connect two nodes. They use the format `Node -- Node` (line only) or
+`Node -> Node` (with arrowhead). eg.
+
+- `Node -- My Cool Node`
+- `A -> Node`
+
+### Blockers
+
+You can place a blocker in front of a node by using `[Blocker] Node`. eg.
+
+- `[Blocker] My Cool Node`
+- `[Blocker] A`
+
+### Opportunities
+
+You can draw opportunity arrows by using `[Opportunity] Node +x` or
+`[Opportunity] Node -x`. eg.
+
+- `[Opportunity] My Cool Node -10`
+- `[Opportunity] A +15`
+
+### Modifying the axes
+
+If you need more space for one of the four segments you can use
+`[I] x`, `[II] x`, or `[III] x`. eg.
+
+- `[I] 15`
+- `[II] 35.5`
+- `[III] 80`
+
+The parser doesn't enforce position, so if you put axis iii before axis i,
+you'll get some rendering issues.
+
+[swift-format]: https://github.com/apple/swift-format.git