├── .eslintignore
├── .eslintrc
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── esbuild.config.mjs
├── gnomeyland.txt
├── manifest.json
├── package.json
├── pull_request_template.md
├── src
├── apocalypse.ts
├── constants.ts
├── error.ts
├── gnomeyland.ts
├── main.ts
├── orientation.ts
├── parser.ts
├── region.ts
└── spline.ts
├── styles.css
├── tsconfig.json
└── versions.json
/.eslintignore:
--------------------------------------------------------------------------------
1 | npm node_modules
2 | build
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "parser": "@typescript-eslint/parser",
4 | "plugins": [
5 | "@typescript-eslint"
6 | ],
7 | "extends": [
8 | "eslint:recommended",
9 | "plugin:@typescript-eslint/eslint-recommended",
10 | "plugin:@typescript-eslint/recommended"
11 | ],
12 | "parserOptions": {
13 | "sourceType": "module"
14 | },
15 | "rules": {
16 | "no-unused-vars": "off",
17 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
18 | "@typescript-eslint/ban-ts-comment": "off",
19 | "no-prototype-builtins": "off",
20 | "@typescript-eslint/no-empty-function": "off"
21 | }
22 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 |
3 | # vscode
4 | .vscode
5 |
6 | # npm
7 | node_modules
8 | package-lock.json
9 | yarn.lock
10 |
11 | # Exclude sourcemaps
12 | *.map
13 |
14 | # obsidian
15 | data.json
16 | main.js
17 |
18 | # Intellij IDEA
19 | /.idea/
20 | *.iml
21 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 4,
3 | "useTabs": false
4 | }
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Michael Hansen
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Obsidian Text Mapper
2 |
3 | This plugin renders hex maps in Obsidian. It is a Typescript port of [Text Mapper](https://alexschroeder.ch/cgit/text-mapper/about/) originally written in Perl by [Alex Schroeder](https://alexschroeder.ch/wiki/Text_Mapper).
4 |
5 |
6 |
7 | The original Text Mapper by Alex Schroeder is licensed under the [GNU Affero General Public License, Version 3](https://www.gnu.org/licenses/agpl-3.0.txt). The Gnomeyland icons by Gregory B. MacKenzie are licensed und the [Creative Commons Attribution-ShareAlike 4.0 International License](http://creativecommons.org/licenses/by-sa/4.0/).
8 |
9 | ### Notes and changes
10 |
11 | - This port does not include support for square grids, verticality, or the `include` command.
12 | - I have included the Gnomeyland Map Icons created by Gregory B. MacKenzie by default. No other icon set is supported.
13 | - Added support for "pointy top" hexes. To switch between modes, simply add `option horizontal` to your block.
14 | - Added support for links in labels. To add a link, use the following syntax: `link|label`. The link will be applied to the entire label.
15 | - Added the ability to swap even and odd positions. With flat top hexes, for a hex (_x_, _y_), (_x_+1, _y_) is to the to the southeast if _x_ is odd and to the northeast if _x_ is even. Adding `option swap-even-odd` will reverse this: (_x_+1, _y_) is to the to the southeast if _x_ is even and to the northeast if _x_ is odd. This swapped numbering scheme is compatible with maps like the one for [Wolves Upon the Coast](https://lukegearing.itch.io/wolves-upon-the-coast-grand-campaign).
16 | - Added support for very simple coordinate formatting. Use `option coordinates-format`.
17 | - The formatter will replace `{X}` with the x value and `{Y}` with the Y value.
18 | - EXAMPLE: To render (4, 5) as `04.05`, use `option coordinates-format {X}.{Y}`.
19 | - Namespaced all element IDs: HTML/SVG assumes that a DOM element `id` is unique. Prior to this change, we did not ensure unique IDs, which led to issues when there was more than one map in the same document.
20 | - You can turn off namespacing by using `option global`.
21 |
22 | I have used this with the output of the "Alpine" maps generated by the original Text Mapper. It takes a while to render very large maps, so be patient. But it works! Here's an example that was generated in Obsidian:
23 |
24 |
25 |
26 | ### Additional Resources
27 |
28 | - Text Mapper: https://campaignwiki.org/text-mapper
29 | - Alex Schroeder's blog explaining how to use Text Mapper: https://alexschroeder.ch/wiki/Text_Mapper
30 | - Perl source for Text Mapper: https://alexschroeder.ch/cgit/text-mapper/
31 |
32 | ## Development
33 |
34 | - Clone this repo.
35 | - `npm i` or `yarn` to install dependencies
36 | - `npm run dev` to start compilation in watch mode.
37 |
38 | ### Manually installing the plugin
39 |
40 | This is paraphrasing information from Obsidian's documentation: [Community plugins](https://help.obsidian.md/Extending+Obsidian/Community+plugins) and [Build plugins](https://help.obsidian.md/Developers/Build+plugins).
41 |
42 | - Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/text-mapper/`.
43 | - If you ran `npm run dev`, these will be in the root folder.
44 | - i.e. `cp main.js styles.css manifest.json ~/Dropbox/Obsidian/.obsidian/plugins/text-mapper`
45 | - You can get a prebuilt version [in the releases tab](https://github.com/modality/obsidian-text-mapper/releases)
46 | - In Obsidian, navigate to Preferences > Community plugins. Toggle `Text Mapper` on.
47 |
48 | ### Example
49 |
50 | This is pulled from: https://campaignwiki.org/contrib/gnomeyland-example.txt
51 |
52 | However, since Gnomeyland icons are included by default, the `include gnomeyland.txt` line is not required. The following block should produce output similar to this:
53 |
54 |
55 |
56 | ````
57 | ```text-mapper
58 | 0101 tree "tree"
59 | 0102 trees "trees"
60 | 0103 forest "forest"
61 | 0201 bush "bush"
62 | 0202 bushes "bushes"
63 | 0203 brushland "brushland"
64 | 0301 fir "fir"
65 | 0302 firs "firs"
66 | 0303 fir-forest "fir-forest"
67 | 0401 hill "hill"
68 | 0402 mountain "mountain"
69 | 0403 mountains "mountains"
70 | 0501 fir-hill "fir-hill"
71 | 0502 fir-mountain "fir-mountain"
72 | 0503 fir-mountains "fir-mountains"
73 | 0601 forest-hill "forest-hill"
74 | 0602 forest-mountain "forest-mountain"
75 | 0603 forest-mountains "forest-mountains"
76 | 0604 fields "fields"
77 | 0605 desert "desert"
78 | 0701 grass "grass"
79 | 0702 marsh "marsh"
80 | 0703 swamp "swamp"
81 | 0704 lake "lake"
82 | 0705 shrine "shrine"
83 | 0801 keep "keep"
84 | 0802 tower "tower"
85 | 0803 castle "castle"
86 | 0804 law "law"
87 | 0805 chaos "chaos"
88 | 0806 swamp2 "swamp2"
89 | 0901 thorp "thorp"
90 | 0902 village "village"
91 | 0903 town "town"
92 | 0904 large-town "large-town"
93 | 0905 city "city"
94 | 1001 dust "dust"
95 | 1002 light-soil "light-soil"
96 | 1003 soil "soil"
97 | 1004 dark-soil "dark-soil"
98 | 1005 sand "sand"
99 | 1006 rock "rock"
100 |
101 | 1101 light-green "light-green"
102 | 1102 green "green"
103 | 1103 dark-green "dark-green"
104 | 1104 blue-green "blue-green"
105 | 1105 water "water"
106 | 1106 ocean "ocean"
107 | 1201 light-grey "light-grey"
108 | 1202 grey "grey"
109 | 1203 dark-grey "dark-grey"
110 | 1204 poisoned "poisoned"
111 | 1205 zone "zone"
112 |
113 | # trail example and larger label example
114 | 0106 dark-green fir-forest "Deep Forest" 30
115 | 0206 green bushes
116 | 0306 soil keep "The Keep"
117 | 0406 light-soil town "Safe Town"
118 | 0005-0506 trail "The Auld Trail" 30%
119 |
120 | # larger label example
121 | other Small Example
122 | ```
123 | ````
124 |
--------------------------------------------------------------------------------
/esbuild.config.mjs:
--------------------------------------------------------------------------------
1 | import esbuild from "esbuild";
2 | import process from "process";
3 | import builtins from 'builtin-modules'
4 |
5 | const banner =
6 | `/*
7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
8 | if you want to view the source, please visit the github repository of this plugin
9 | */
10 | `;
11 |
12 | const prod = (process.argv[2] === 'production');
13 |
14 | esbuild.build({
15 | banner: {
16 | js: banner,
17 | },
18 | entryPoints: ['./src/main.ts'],
19 | bundle: true,
20 | external: ['obsidian', 'electron', ...builtins],
21 | format: 'cjs',
22 | watch: !prod,
23 | target: 'es2016',
24 | logLevel: "info",
25 | sourcemap: prod ? false : 'inline',
26 | treeShaking: true,
27 | outdir: '.'
28 | }).catch(() => process.exit(1));
29 |
--------------------------------------------------------------------------------
/gnomeyland.txt:
--------------------------------------------------------------------------------
1 | # Gnomeyland Map Icons Copyright Gregory B. MacKenzie 2012, Alex Schroeder 2013-2019.
2 | # This work is licensed under the Creative Commons
3 | # Attribution-ShareAlike 4.0 International License. To view a copy of this
4 | # license, visit http://creativecommons.org/licenses/by-sa/4.0/.
5 | # license Gnomeyland SVG Map Icons Copyright Gregory B. MacKenzie 2012, Alex Schroeder 2013-2019. This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
6 | # This file is for use with text-mapper.
7 | # https://alexschroeder.ch/cgit/text-mapper/about/
8 |
9 | default attributes fill="none" stroke="black" stroke-width="3"
10 |
11 | text font-size="20pt" dy="15px"
12 | label font-size="20pt" dy="5px"
13 | glow stroke="white" stroke-width="5pt"
14 |
15 | # Colors
16 |
17 | # object shadow: #fecb86
18 |
19 | trail path attributes stroke="#e3bea3" stroke-width="6" fill="none"
20 | river path attributes transform="translate(20,10)" stroke="#6ebae7" stroke-width="8" fill="none" opacity="0.7"
21 | canyon path attributes transform="translate(20,10)" stroke="black" stroke-width="24" fill="none" opacity="0.2"
22 |
23 | # paper white
24 | white attributes fill="#f7f7f7"
25 | shadow attributes fill="black" opacity="0.2"
26 |
27 | # agriculture
28 | light-soil attributes fill="#e5de47"
29 | soil attributes fill="#b0b446"
30 | dark-soil attributes fill="#c97457"
31 |
32 | # coastlines, deserts
33 | rock attributes fill="#b0856a"
34 | dust attributes fill="#ede787"
35 | sand attributes fill="#e3bea3"
36 | water attributes fill="#6ebae7"
37 | ocean attributes fill="#1c86ee"
38 |
39 | # wetlands
40 | light-grey attributes fill="#dcddbe"
41 | grey attributes fill="#afbc9e"
42 | dark-grey attributes fill="#859d70"
43 | blue-green attributes fill="#6f9487"
44 | poisoned attributes fill="#af84a5"
45 |
46 | # suitable for forests
47 | light-green attributes fill="#b7c18c"
48 | green attributes fill="#77904c"
49 | dark-green attributes fill="#2d501a"
50 |
51 | # invisible stuff that we need to create correct PDF files
52 | dry attributes fill="black" opacity="0.1"
53 | port attributes opacity="0"
54 |
55 | # debug
56 | red attributes fill="red" opacity="0.8" transform="scale(0.5)"
57 |
58 | # Buildings
59 |
60 |
61 |
62 |
63 |
64 |
65 | # Settlement Library
66 |
67 |
68 |
69 |
70 |
71 |
72 | # Settlement Icons
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | # Deciduous Tree
82 |
83 |
84 |
85 |
86 |
87 |
88 | # Bush
89 |
90 |
91 |
92 |
93 |
94 | # Coniferous forest
95 |
96 |
97 |
98 |
99 |
100 | # Hills & Mountains
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 | # Simple things to layer on top
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 | # cliffs for hex maps
124 |
125 |
126 |
127 |
128 |
129 |
130 | # cliffs for square maps
131 |
132 |
133 |
134 |
135 |
136 | # lakes
137 |
--------------------------------------------------------------------------------
/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "text-mapper",
3 | "name": "Text Mapper",
4 | "version": "1.1.0",
5 | "minAppVersion": "0.12.0",
6 | "description": "Render hex maps in Obsidian",
7 | "author": "Michael Hansen",
8 | "authorUrl": "https://github.com/modality",
9 | "isDesktopOnly": false
10 | }
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "text-mapper",
3 | "version": "0.1.0",
4 | "description": "Render hex maps in Obsidian. (https://obsidian.md)",
5 | "main": "main.js",
6 | "scripts": {
7 | "dev": "node esbuild.config.mjs",
8 | "build": "node esbuild.config.mjs production"
9 | },
10 | "keywords": [],
11 | "author": "",
12 | "license": "MIT",
13 | "devDependencies": {
14 | "@types/node": "^16.11.6",
15 | "@typescript-eslint/eslint-plugin": "^5.2.0",
16 | "@typescript-eslint/parser": "^5.2.0",
17 | "builtin-modules": "^3.2.0",
18 | "debug": "^4.3.3",
19 | "esbuild": "0.13.12",
20 | "obsidian": "1.4.11",
21 | "tslib": "2.3.1",
22 | "typescript": "4.4.4"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## What it is
2 |
3 | Please describe the change and make a few screenshots so I can confirm that everything looks good! Thank you.
4 |
5 | ## Tests
6 |
7 | Paste the sample code used for the before and after screenshots:
8 |
9 | ```
10 | 0101 tree "tree"
11 | 0102 trees "trees"
12 | 0103 forest "forest"
13 | 0201 bush "bush"
14 | 0202 bushes "bushes"
15 | 0203 brushland "brushland"
16 | 0301 fir "fir"
17 | 0302 firs "firs"
18 | 0303 fir-forest "fir-forest"
19 | 0401 hill "hill"
20 | 0402 mountain "mountain"
21 | 0403 mountains "mountains"
22 | 0501 fir-hill "fir-hill"
23 | 0502 fir-mountain "fir-mountain"
24 | 0503 fir-mountains "fir-mountains"
25 | 0601 forest-hill "forest-hill"
26 | 0602 forest-mountain "forest-mountain"
27 | 0603 forest-mountains "forest-mountains"
28 | 0604 fields "fields"
29 | 0605 desert "desert"
30 | 0701 grass "grass"
31 | 0702 marsh "marsh"
32 | 0703 swamp "swamp"
33 | 0704 lake "lake"
34 | 0705 shrine "shrine"
35 | 0801 keep "keep"
36 | 0802 tower "tower"
37 | 0803 castle "castle"
38 | 0804 law "law"
39 | 0805 chaos "chaos"
40 | 0806 swamp2 "swamp2"
41 | 0901 thorp "thorp"
42 | 0902 village "village"
43 | 0903 town "town"
44 | 0904 large-town "large-town"
45 | 0905 city "city"
46 | 1001 dust "dust"
47 | 1002 light-soil "light-soil"
48 | 1003 soil "soil"
49 | 1004 dark-soil "dark-soil"
50 | 1005 sand "sand"
51 | 1006 rock "rock"
52 |
53 | 1101 light-green "light-green"
54 | 1102 green "green"
55 | 1103 dark-green "dark-green"
56 | 1104 blue-green "blue-green"
57 | 1105 water "water"
58 | 1106 ocean "ocean"
59 | 1201 light-grey "light-grey"
60 | 1202 grey "grey"
61 | 1203 dark-grey "dark-grey"
62 | 1204 poisoned "poisoned"
63 | 1205 zone "zone"
64 |
65 | # trail example and larger label example
66 | 0106 dark-green fir-forest "Deep Forest" 30
67 | 0206 green bushes
68 | 0306 soil keep "The Keep"
69 | 0406 light-soil town "Safe Town"
70 | 0005-0506 trail "The Auld Trail" 30%
71 |
72 | # larger label example
73 | other Small Example
74 | ```
75 |
76 | ### Before Screenshot
77 |
78 | Paste a screenshot of an example of what it looks like BEFORE you made your fix.
79 |
80 | ### After Screenshot
81 |
82 | Paste a screenshot of an example of what it looks like AFTER you made your fix.
83 |
84 | ### Baseline Screenshot
85 |
86 | Paste a screenshot of what the example in README.md looks like. (It's also the example in this document).
87 |
--------------------------------------------------------------------------------
/src/apocalypse.ts:
--------------------------------------------------------------------------------
1 | export const APOCALYPSE = `
2 | # Apocalypse
3 |
4 | # This file is for use with text-mapper.
5 | # https://alexschroeder.ch/cgit/text-mapper/about/
6 |
7 | # To the extent possible under law, the authors have waived all
8 | # copyright and related or neighboring rights to this work.
9 | # https://creativecommons.org/publicdomain/zero/1.0/
10 |
11 | default attributes fill="none" stroke="black" stroke-width="3"
12 | apoc-sand attributes fill="#eedd82"
13 | apoc-coast attributes fill="#7fffd4"
14 | apoc-sea attributes fill="#4169e1"
15 | apoc-desert attributes fill="#ede787"
16 | apoc-red attributes fill="red"
17 | apoc-debug attributes fill="red" opacity="0.8" transform="scale(0.5)"
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | # Black with white border
32 |
33 |
34 |
35 |
36 |
37 | # Black only
38 | #
39 | #
40 | #
41 | #
42 |
43 | apoc-road path attributes stroke="black" stroke-width="3" fill-opacity="0" stroke-dasharray="10 10"
44 | apoc-river path attributes stroke="#66cdaa" stroke-width="10" fill-opacity="0" stroke-linecap="round"
45 | apoc-border path attributes stroke="red" stroke-width="15" stroke-opacity="0.5" fill-opacity="0"
46 | `;
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const ATTRIBUTES_REGEX = /^(\S+)\s+attributes\s+(.*)/;
2 | export const PATH_ATTRIBUTES_REGEX = /^(\S+)\s+path\s+attributes\s+(.*)/;
3 | export const PATH_REGEX = /^(\S+)\s+path\s+(.*)/;
4 | export const XML_REGEX = /^(<.*>)/;
5 | export const TEXT_REGEX = /^text\s+(.*)/;
6 | export const GLOW_REGEX = /^glow\s+(.*)/;
7 | export const LABEL_REGEX = /^label\s+(.*)/;
8 | export const OPTION_REGEX = /^option\s+(.*)/;
9 | export const HEX_REGEX = /^(-?\d\d)(-?\d\d)(\d\d)?\s+(.*)/;
10 | export const HEX_LABEL_REGEX = /["]([^"]+)["]\s*(\d+)?/;
11 | export const SPLINE_REGEX =
12 | /^(-?\d\d-?\d\d(?:\d\d)?(?:--?\d\d-?\d\d(?:\d\d)?)+)\s+(\S+)\s*(?:["“](.+)["”])?\s*(left|right)?\s*(\d+%)?/;
13 | export const SPLINE_ELEMENT_SPLIT_REGEX = /^(-?\d\d-?\d\d)-?(.* )/;
14 | export const SPLINE_POINT_REGEX = /(-?\d\d)(-?\d\d)/;
15 | export const ATTRIBUTE_MAP_REGEX = /(\S+)="([^"]+)"/g;
16 | export const SVG_CHOMP_WHITESPACE_REGEX = /(>)(\s+)(<)/g;
17 | export const SVG_ID_REGEX = /(id=")(\S+)(")/g;
18 | export const SVG_HREF_REGEX = /(xlink:href="#)(\S+)(")/g;
19 |
20 | export interface SVGElement {
21 | createSvg(tag: string, options?: any): SVGElement;
22 | innerHTML: string;
23 | textContent: string;
24 | }
25 |
26 | export type NamespaceFunction = {
27 | (what: string): string;
28 | };
29 |
--------------------------------------------------------------------------------
/src/error.ts:
--------------------------------------------------------------------------------
1 | import { MarkdownRenderChild } from "obsidian";
2 |
3 | export class ParseError extends MarkdownRenderChild {
4 | outputEl: HTMLDivElement;
5 |
6 | constructor(containerEl: HTMLElement) {
7 | super(containerEl);
8 | containerEl.innerText = "Error!";
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/gnomeyland.ts:
--------------------------------------------------------------------------------
1 | export const GNOMEYLAND = `
2 | default attributes fill="none" stroke="black" stroke-width="3"
3 | text font-size="20pt" dy="15px"
4 | label font-size="20pt" dy="5px"
5 | glow stroke="white" stroke-width="5pt"
6 |
7 | # Colors
8 | # object shadow: #fecb86
9 |
10 | trail path attributes stroke="#e3bea3" stroke-width="6" fill="none"
11 | river path attributes transform="translate(20,10)" stroke="#6ebae7" stroke-width="8" fill="none" opacity="0.7"
12 | canyon path attributes transform="translate(20,10)" stroke="black" stroke-width="24" fill="none" opacity="0.2"
13 |
14 | # paper white
15 | white attributes fill="#f7f7f7"
16 | shadow attributes fill="black" opacity="0.2"
17 |
18 | # agriculture
19 | light-soil attributes fill="#e5de47"
20 | soil attributes fill="#b0b446"
21 | dark-soil attributes fill="#c97457"
22 |
23 | # coastlines, deserts
24 | rock attributes fill="#b0856a"
25 | dust attributes fill="#ede787"
26 | sand attributes fill="#e3bea3"
27 | water attributes fill="#6ebae7"
28 | ocean attributes fill="#1c86ee"
29 |
30 | # wetlands
31 | light-grey attributes fill="#dcddbe"
32 | grey attributes fill="#afbc9e"
33 | dark-grey attributes fill="#859d70"
34 | blue-green attributes fill="#6f9487"
35 | poisoned attributes fill="#af84a5"
36 |
37 | # suitable for forests
38 | light-green attributes fill="#b7c18c"
39 | green attributes fill="#77904c"
40 | dark-green attributes fill="#2d501a"
41 |
42 | # invisible stuff that we need to create correct PDF files
43 | dry attributes fill="black" opacity="0.1"
44 | port attributes opacity="0"
45 |
46 | # debug
47 | red attributes fill="red" opacity="0.8" transform="scale(0.5)"
48 |
49 | # Buildings
50 |
51 |
52 |
53 |
54 |
55 |
56 | # Settlement Library
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | # Settlement Icons
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | # Deciduous Tree
75 |
76 |
77 |
78 |
79 |
80 |
81 | # Bush
82 |
83 |
84 |
85 |
86 |
87 | # Coniferous forest
88 |
89 |
90 |
91 |
92 |
93 | # Hills & Mountains
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 | # Simple things to layer on top
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 | # cliffs for hex maps
117 |
118 |
119 |
120 |
121 |
122 |
123 | # cliffs for square maps
124 |
125 |
126 |
127 |
128 |
129 | # lakes
130 |
131 | `;
132 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import {
2 | MarkdownPostProcessorContext,
3 | MarkdownRenderChild,
4 | Plugin,
5 | } from "obsidian";
6 | import { APOCALYPSE } from "./apocalypse";
7 | import { ParseError } from "./error";
8 | import { GNOMEYLAND } from "./gnomeyland";
9 | import { TextMapperParser } from "./parser";
10 |
11 | export default class TextMapperPlugin extends Plugin {
12 | async onload() {
13 | console.log("Loading Obsidian TextMapper.");
14 | this.registerMarkdownCodeBlockProcessor(
15 | "text-mapper",
16 | this.processMarkdown.bind(this)
17 | );
18 | }
19 |
20 | async processMarkdown(
21 | source: string,
22 | el: HTMLElement,
23 | ctx: MarkdownPostProcessorContext
24 | ): Promise {
25 | try {
26 | ctx.addChild(new TextMapper(el, ctx.docId, source));
27 | } catch (e) {
28 | console.log("text mapper error", e);
29 | ctx.addChild(new ParseError(el));
30 | }
31 | }
32 |
33 | onunload() {}
34 | }
35 |
36 | export class TextMapper extends MarkdownRenderChild {
37 | textMapperEl: HTMLDivElement;
38 |
39 | constructor(containerEl: HTMLElement, docId: string, source: string) {
40 | super(containerEl);
41 | this.textMapperEl = this.containerEl.createDiv({ cls: "textmapper" });
42 |
43 | const totalSource = source
44 | .split("\n")
45 | .concat(GNOMEYLAND.split("\n"))
46 | .concat(APOCALYPSE.split("\n"));
47 |
48 | const parser = new TextMapperParser(docId);
49 | parser.process(totalSource);
50 | parser.svg(this.textMapperEl);
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/orientation.ts:
--------------------------------------------------------------------------------
1 | import { Region } from "./region";
2 |
3 | export class Point {
4 | x: number;
5 | y: number;
6 |
7 | constructor(x: number, y: number) {
8 | this.x = x;
9 | this.y = y;
10 | }
11 |
12 | toString(): string {
13 | return `${this.x.toFixed(1)},${this.y.toFixed(1)}`;
14 | }
15 |
16 | eq(pt: Point): boolean {
17 | return this.x == pt.x && this.y == pt.y;
18 | }
19 | }
20 |
21 | export class Orientation {
22 | flatTop: boolean;
23 | swapEvenOdd: boolean;
24 | dy: number;
25 | dx: number;
26 | labelOffset: number;
27 |
28 | constructor(flatTop: boolean = true, swapEvenOdd: boolean = false) {
29 | this.flatTop = flatTop;
30 | this.swapEvenOdd = swapEvenOdd;
31 | if (this.flatTop) {
32 | this.dx = 100;
33 | this.dy = (100 * Math.sqrt(3)) / 2;
34 | this.labelOffset = 0.8;
35 | } else {
36 | this.dx = (100 * Math.sqrt(3)) / 2;
37 | this.dy = 100;
38 | this.labelOffset = 0.58;
39 | }
40 | }
41 |
42 | viewbox(regions: Region[]): number[] {
43 | const xMargin = 60 + this.dx;
44 | const yMargin = 60 + this.dy;
45 | let min_x_overall = undefined;
46 | let max_x_overall = undefined;
47 | let min_y_overall = undefined;
48 | let max_y_overall = undefined;
49 |
50 | const pixels: Point[] = regions.map((r) =>
51 | this.pixels(new Point(r.x, r.y), 0, 0)
52 | );
53 |
54 | for (const pixel of pixels) {
55 | if (min_x_overall == undefined || pixel.x < min_x_overall) {
56 | min_x_overall = pixel.x;
57 | }
58 | if (min_y_overall == undefined || pixel.y < min_y_overall) {
59 | min_y_overall = pixel.y;
60 | }
61 | if (max_x_overall == undefined || pixel.x > max_x_overall) {
62 | max_x_overall = pixel.x;
63 | }
64 | if (max_y_overall == undefined || pixel.y > max_y_overall) {
65 | max_y_overall = pixel.y;
66 | }
67 | }
68 |
69 | return [
70 | min_x_overall - xMargin,
71 | min_y_overall - yMargin,
72 | max_x_overall + xMargin,
73 | max_y_overall + yMargin,
74 | ];
75 | }
76 |
77 | hexCorners(): Point[] {
78 | if (this.flatTop) {
79 | return [
80 | new Point(-this.dx, 0),
81 | new Point(-this.dx / 2, this.dy),
82 | new Point(this.dx / 2, this.dy),
83 | new Point(this.dx, 0),
84 | new Point(this.dx / 2, -this.dy),
85 | new Point(-this.dx / 2, -this.dy),
86 | ];
87 | } else {
88 | return [
89 | new Point(0, -this.dy),
90 | new Point(this.dx, -this.dy / 2),
91 | new Point(this.dx, this.dy / 2),
92 | new Point(0, this.dy),
93 | new Point(-this.dx, this.dy / 2),
94 | new Point(-this.dx, -this.dy / 2),
95 | ];
96 | }
97 | }
98 |
99 | pixels(pt: Point, offsetX: number = 0, offsetY: number = 0): Point {
100 | if (this.flatTop) {
101 | const evenOdd = (this.swapEvenOdd ? 1 : 0) * (pt.x % 2);
102 | const x = (pt.x * this.dx * 3) / 2 + offsetX;
103 | const y =
104 | (pt.y + evenOdd) * this.dy * 2 -
105 | (Math.abs(pt.x) % 2) * this.dy +
106 | offsetY;
107 | return new Point(x, y);
108 | } else {
109 | const evenOdd = (this.swapEvenOdd ? 1 : 0) * (pt.y % 2);
110 | const x =
111 | (pt.x + evenOdd) * this.dx * 2 -
112 | (Math.abs(pt.y) % 2) * this.dx +
113 | offsetX;
114 | const y = (pt.y * this.dy * 3) / 2 + offsetY;
115 | return new Point(x, y);
116 | }
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/parser.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ATTRIBUTES_REGEX,
3 | PATH_ATTRIBUTES_REGEX,
4 | OPTION_REGEX,
5 | PATH_REGEX,
6 | XML_REGEX,
7 | TEXT_REGEX,
8 | GLOW_REGEX,
9 | LABEL_REGEX,
10 | HEX_REGEX,
11 | HEX_LABEL_REGEX,
12 | SPLINE_REGEX,
13 | SPLINE_ELEMENT_SPLIT_REGEX,
14 | SPLINE_POINT_REGEX,
15 | ATTRIBUTE_MAP_REGEX,
16 | SVGElement,
17 | SVG_CHOMP_WHITESPACE_REGEX,
18 | SVG_ID_REGEX,
19 | SVG_HREF_REGEX,
20 | } from "./constants";
21 | import { Point, Orientation } from "./orientation";
22 | import { Region } from "./region";
23 | import { Spline } from "./spline";
24 |
25 | // https://alexschroeder.ch/cgit/text-mapper/tree/lib/Game/TextMapper/Mapper.pm
26 | export class TextMapperParser {
27 | id: string;
28 | pathId: number;
29 | options: any;
30 | regions: Region[]; // ' => sub { [] };
31 | attributes: any; // ' => sub { {} };
32 | defs: string[]; // ' => sub { [] };
33 | path: any; // ' => sub { {} };
34 | splines: Spline[]; // ' => sub { [] };
35 | pathAttributes: any; // ' => sub { {} };
36 | textAttributes: any;
37 | glowAttributes: any;
38 | labelAttributes: any;
39 | orientation: Orientation;
40 | // messages: string[]; // ' => sub { [] };
41 |
42 | constructor(id: string) {
43 | this.id = id;
44 | this.options = {
45 | horizontal: false,
46 | "coordinates-format": "{X}{Y}",
47 | "swap-even-odd": false,
48 | global: false,
49 | };
50 | this.regions = [];
51 | this.attributes = {};
52 | this.defs = [];
53 | this.path = {};
54 | this.splines = [];
55 | this.pathAttributes = {};
56 | this.textAttributes = "";
57 | this.glowAttributes = "";
58 | this.labelAttributes = "";
59 | }
60 |
61 | /**
62 | * Append the parser ID to a string. In practice, the parser ID is the
63 | * Obsidian document ID. This function is used when setting the `id`
64 | * attribute of SVG elements, so that the attribute is unique to a given
65 | * map, which prevents path definitions from carrying over in
66 | * documents with more than one map.
67 | */
68 | private namespace(what: string) {
69 | if (this.options.global) {
70 | return `${what}`;
71 | }
72 | return `${what}-${this.id}`;
73 | }
74 |
75 | /**
76 | * Process the source code of a map, line by line.
77 | */
78 | process(lines: string[]) {
79 | this.pathId = 0;
80 |
81 | // First, set all options.
82 | for (const line of lines) {
83 | if (line.startsWith("#")) {
84 | continue;
85 | }
86 | if (OPTION_REGEX.test(line)) {
87 | const match = line.match(OPTION_REGEX);
88 | this.parseOption(match[1]);
89 | }
90 | }
91 |
92 | if (this.options.horizontal) {
93 | this.orientation = new Orientation(
94 | false,
95 | this.options["swap-even-odd"]
96 | );
97 | } else {
98 | this.orientation = new Orientation(
99 | true,
100 | this.options["swap-even-odd"]
101 | );
102 | }
103 |
104 | // Then,
105 | for (const line of lines) {
106 | if (line.startsWith("#")) {
107 | continue;
108 | }
109 | if (HEX_REGEX.test(line)) {
110 | const region = this.parseRegion(line);
111 | this.regions.push(region);
112 | } else if (SPLINE_REGEX.test(line)) {
113 | const spline = this.parsePath(line);
114 | this.splines.push(spline);
115 | } else if (ATTRIBUTES_REGEX.test(line)) {
116 | const match = line.match(ATTRIBUTES_REGEX);
117 | this.attributes[match[1]] = this.parseAttributes(match[2]);
118 | } else if (XML_REGEX.test(line)) {
119 | const match = line.match(XML_REGEX);
120 | this.def(match[1]);
121 | } else if (PATH_ATTRIBUTES_REGEX.test(line)) {
122 | const match = line.match(PATH_ATTRIBUTES_REGEX);
123 | this.pathAttributes[match[1]] = this.parseAttributes(match[2]);
124 | } else if (PATH_REGEX.test(line)) {
125 | const match = line.match(PATH_REGEX);
126 | this.path[match[1]] = match[2];
127 | } else if (TEXT_REGEX.test(line)) {
128 | const match = line.match(TEXT_REGEX);
129 | this.textAttributes = this.parseAttributes(match[1]);
130 | } else if (GLOW_REGEX.test(line)) {
131 | const match = line.match(GLOW_REGEX);
132 | this.glowAttributes = this.parseAttributes(match[1]);
133 | } else if (LABEL_REGEX.test(line)) {
134 | const match = line.match(LABEL_REGEX);
135 | this.labelAttributes = this.parseAttributes(match[1]);
136 | }
137 | }
138 | }
139 |
140 | parseRegion(line: string) {
141 | // hex
142 | const match = line.match(HEX_REGEX);
143 | const region = this.makeRegion(match[1], match[2], match[3] || "00");
144 | let rest = match[4];
145 | while (HEX_LABEL_REGEX.test(rest)) {
146 | const labelMatch = rest.match(HEX_LABEL_REGEX);
147 | region.label = labelMatch[1];
148 | region.size = labelMatch[2];
149 | rest = rest.replace(HEX_LABEL_REGEX, "");
150 | }
151 | const types = rest.split(/\s+/).filter((t) => t.length > 0);
152 | region.types = types;
153 | return region;
154 | }
155 |
156 | parsePath(line: string) {
157 | // path
158 | const match = line.match(SPLINE_REGEX);
159 | const spline = this.makeSpline();
160 | spline.types = match[2];
161 | spline.label = match[3];
162 | spline.side = match[4];
163 | spline.start = match[5];
164 |
165 | let rest = line;
166 | while (true) {
167 | let segment: string;
168 | [segment, rest] = this.splitPathSegments(rest);
169 | if (segment === null) {
170 | break;
171 | }
172 | const pointMatch = segment.match(SPLINE_POINT_REGEX);
173 | spline.addPoint(pointMatch[1], pointMatch[2]);
174 | }
175 | return spline;
176 | }
177 |
178 | private splitPathSegments(splinePath: string): [string, string] {
179 | let match = splinePath.match(SPLINE_ELEMENT_SPLIT_REGEX);
180 | if (match === null) {
181 | return [null, splinePath];
182 | }
183 | return [match[1], match[2]];
184 | }
185 |
186 | def(what: string) {
187 | let svg = what.replace(SVG_CHOMP_WHITESPACE_REGEX, "$1$3");
188 | let match;
189 | while ((match = SVG_ID_REGEX.exec(svg))) {
190 | svg = svg.replace(
191 | match[0],
192 | `${match[1]}${this.namespace(match[2])}${match[3]}`
193 | );
194 | }
195 | while ((match = SVG_HREF_REGEX.exec(svg))) {
196 | svg = svg.replace(
197 | match[0],
198 | `${match[1]}${this.namespace(match[2])}${match[3]}`
199 | );
200 | }
201 | this.defs.push(svg);
202 | }
203 |
204 | makeRegion(x: string, y: string, z: string): Region {
205 | const region = new Region(this.namespace.bind(this));
206 | region.x = parseInt(x);
207 | region.y = parseInt(y);
208 | region.id = `hex.${region.x}.${region.y}`;
209 | return region;
210 | }
211 |
212 | makeSpline(): Spline {
213 | const spline = new Spline();
214 | this.pathId++;
215 | spline.id = this.namespace(`path-${this.pathId}`);
216 | return spline;
217 | }
218 |
219 | parseAttributes(attrs: string): any {
220 | const output: any = {};
221 | let matches;
222 | while ((matches = ATTRIBUTE_MAP_REGEX.exec(attrs))) {
223 | output[matches[1]] = matches[2];
224 | }
225 | return output;
226 | }
227 |
228 | /**
229 | * This parses custom options which allow for turning on and off different
230 | * rendering options. For an option set in a map like this:
231 | *
232 | * option NAME X Y Z
233 | *
234 | * The parameters will be parsed into a string[]: ["NAME", "X", "Y", "Z"]
235 | * The key would be "NAME".
236 | */
237 | parseOption(optionStr: string): any {
238 | const option: any = {
239 | valid: false,
240 | key: "",
241 | value: "",
242 | };
243 |
244 | // Tokenize the option and set the key
245 | const tokens = optionStr.split(" ");
246 | if (tokens.length < 1) {
247 | return option;
248 | }
249 | option.key = tokens[0];
250 |
251 | // Validate the option
252 | if (option.key === "horizontal" || option.key === "swap-even-odd") {
253 | option.valid = true;
254 | option.value = true;
255 | } else if (option.key === "coordinates-format") {
256 | option.valid = true;
257 | option.value = tokens.slice(1).join(" ");
258 | } else if (option.key === "global") {
259 | option.valid = true;
260 | option.value = true;
261 | }
262 |
263 | // If the option is valid, then set it in this.options. It can now be
264 | // used throughout the rendering code.
265 | if (option.valid) {
266 | this.options[option.key] = option.value;
267 | }
268 | }
269 |
270 | shape(svgEl: SVGElement, attributes: any) {
271 | const points = this.orientation
272 | .hexCorners()
273 | .map((corner: Point) => corner.toString())
274 | .join(" ");
275 | svgEl.createSvg("polygon", {
276 | attr: {
277 | ...attributes,
278 | points,
279 | },
280 | });
281 | // return ``;
282 | }
283 |
284 | svgHeader(el: HTMLElement): SVGElement {
285 | if (this.regions.length == 0) {
286 | // @ts-ignore
287 | return el.createSvg("svg");
288 | }
289 |
290 | const [vx1, vy1, vx2, vy2] = this.orientation.viewbox(this.regions);
291 | const width = (vx2 - vx1).toFixed(0);
292 | const height = (vy2 - vy1).toFixed(0);
293 |
294 | // @ts-ignore
295 | const svgEl: SVGElement = el.createSvg("svg", {
296 | attr: {
297 | "xmlns:xlink": "http://www.w3.org/1999/xlink",
298 | viewBox: `${vx1} ${vy1} ${width} ${height}`,
299 | },
300 | });
301 |
302 | svgEl.createSvg("rect", {
303 | attr: {
304 | x: vx1,
305 | y: vy1,
306 | width: width,
307 | height: height,
308 | fill: "white",
309 | },
310 | });
311 |
312 | return svgEl;
313 | }
314 |
315 | svgDefs(svgEl: SVGElement): void {
316 | // All the definitions are included by default.
317 | const defsEl = svgEl.createSvg("defs");
318 | defsEl.innerHTML = this.defs.join("\n");
319 |
320 | // collect region types from attributes and paths in case the sets don't overlap
321 | const types: any = {};
322 | for (const region of this.regions) {
323 | for (const rtype of region.types) {
324 | types[rtype] = 1;
325 | }
326 | }
327 | for (const spline of this.splines) {
328 | types[spline.types] = 1;
329 | }
330 |
331 | // now go through them all
332 | for (const type of Object.keys(types).sort()) {
333 | const path = this.path[type];
334 | const attributes = this.attributes[type];
335 | if (path || attributes) {
336 | const gEl = defsEl.createSvg("g", {
337 | attr: { id: this.namespace(type) },
338 | });
339 |
340 | // just shapes get a glow, eg. a house (must come first)
341 | if (path && !attributes) {
342 | gEl.createSvg("path", {
343 | attr: {
344 | ...this.glowAttributes,
345 | d: path,
346 | },
347 | });
348 | }
349 | // region with attributes get a shape (square or hex), eg. plains and grass
350 | if (attributes) {
351 | this.shape(gEl, attributes);
352 | }
353 | // and now the attributes themselves the shape itself
354 | if (path) {
355 | gEl.createSvg("path", {
356 | attr: {
357 | ...this.pathAttributes,
358 | d: path,
359 | },
360 | });
361 | }
362 | }
363 | }
364 | }
365 |
366 | svgBackgrounds(svgEl: SVGElement): void {
367 | const bgEl = svgEl.createSvg("g", {
368 | attr: { id: this.namespace("backgrounds") },
369 | });
370 | const whitelist = Object.keys(this.attributes);
371 | for (const region of this.regions) {
372 | region.svg(bgEl, this.orientation, whitelist);
373 | }
374 | }
375 |
376 | svgPaths(svgEl: SVGElement): void {
377 | const splinesEl = svgEl.createSvg("g", {
378 | attr: { id: this.namespace("paths") },
379 | });
380 | for (const spline of this.splines) {
381 | spline.svg(splinesEl, this.orientation, this.pathAttributes);
382 | }
383 | }
384 |
385 | svgThings(svgEl: SVGElement): void {
386 | const thingsEl = svgEl.createSvg("g", {
387 | attr: { id: this.namespace("things") },
388 | });
389 | const blacklist = Object.keys(this.attributes);
390 | for (const region of this.regions) {
391 | const filtered: string[] = region.types.filter(
392 | (t) => !blacklist.includes(t)
393 | );
394 | region.svg(thingsEl, this.orientation, filtered);
395 | }
396 | }
397 |
398 | svgCoordinates(svgEl: SVGElement): void {
399 | const coordsEl = svgEl.createSvg("g", {
400 | attr: { id: this.namespace("coordinates") },
401 | });
402 | for (const region of this.regions) {
403 | region.svgCoordinates(
404 | coordsEl,
405 | this.orientation,
406 | this.textAttributes,
407 | this.options["coordinates-format"]
408 | );
409 | }
410 | }
411 |
412 | svgRegions(svgEl: SVGElement): void {
413 | const regionsEl = svgEl.createSvg("g", {
414 | attr: { id: this.namespace("regions") },
415 | });
416 | const attributes = this.attributes["default"];
417 | for (const region of this.regions) {
418 | region.svgRegion(regionsEl, this.orientation, attributes);
419 | }
420 | }
421 |
422 | svgPathLabels(svgEl: SVGElement): void {
423 | const labelsEl = svgEl.createSvg("g", {
424 | attr: { id: this.namespace("path-labels") },
425 | });
426 | for (const spline of this.splines) {
427 | spline.svgLabel(
428 | labelsEl,
429 | this.labelAttributes,
430 | this.glowAttributes
431 | );
432 | }
433 | }
434 |
435 | svgLabels(svgEl: SVGElement): void {
436 | const labelsEl = svgEl.createSvg("g", {
437 | attr: { id: this.namespace("labels") },
438 | });
439 | for (const region of this.regions) {
440 | region.svgLabel(
441 | labelsEl,
442 | this.orientation,
443 | this.labelAttributes,
444 | this.glowAttributes
445 | );
446 | }
447 | }
448 |
449 | svg(el: HTMLElement) {
450 | const svgEl = this.svgHeader(el);
451 | this.svgDefs(svgEl);
452 | this.svgBackgrounds(svgEl);
453 | this.svgPaths(svgEl);
454 | this.svgThings(svgEl);
455 | this.svgCoordinates(svgEl);
456 | this.svgRegions(svgEl);
457 | this.svgPathLabels(svgEl);
458 | this.svgLabels(svgEl);
459 | return svgEl;
460 | }
461 | }
462 |
--------------------------------------------------------------------------------
/src/region.ts:
--------------------------------------------------------------------------------
1 | import { SVGElement } from "./constants";
2 | import { Point, Orientation } from "./orientation";
3 | import { NamespaceFunction } from "./constants";
4 |
5 | export class Region {
6 | x: number;
7 | y: number;
8 | types: string[];
9 | label: string;
10 | size: string;
11 | id: string;
12 | namespace: NamespaceFunction;
13 |
14 | constructor(namespace: NamespaceFunction) {
15 | this.types = [];
16 | this.namespace = namespace;
17 | }
18 |
19 | pixels(orientation: Orientation, addX: number, addY: number): number[] {
20 | const pix = orientation.pixels(new Point(this.x, this.y), addX, addY);
21 | return [pix.x, pix.y];
22 | }
23 |
24 | svg(svgEl: SVGElement, orientation: Orientation, types: string[]): void {
25 | const pix = orientation.pixels(new Point(this.x, this.y));
26 | for (const type of this.types) {
27 | if (!types.includes(type)) {
28 | continue;
29 | }
30 | const namespaced = this.namespace(type);
31 | svgEl.createSvg("use", {
32 | attr: {
33 | x: pix.x.toFixed(1),
34 | y: pix.y.toFixed(1),
35 | href: `#${namespaced}`,
36 | },
37 | });
38 | }
39 | }
40 |
41 | svgCoordinates(
42 | svgEl: SVGElement,
43 | orientation: Orientation,
44 | textAttributes: any,
45 | coordinatesFormat: string
46 | ): void {
47 | const pix = orientation.pixels(
48 | new Point(this.x, this.y),
49 | 0,
50 | -orientation.dy * orientation.labelOffset
51 | );
52 |
53 | const coordEl = svgEl.createSvg("text", {
54 | attr: {
55 | ...textAttributes,
56 | "text-anchor": "middle",
57 | x: pix.x.toFixed(1),
58 | y: pix.y.toFixed(1),
59 | },
60 | });
61 |
62 | const xStr = this.x.toString().padStart(2, "0");
63 | const yStr = this.y.toString().padStart(2, "0");
64 |
65 | const content = coordinatesFormat
66 | .replace("{X}", xStr)
67 | .replace("{Y}", yStr);
68 |
69 | coordEl.textContent = content;
70 | }
71 |
72 | svgRegion(
73 | svgEl: SVGElement,
74 | orientation: Orientation,
75 | attributes: any
76 | ): void {
77 | const points = orientation
78 | .hexCorners()
79 | .map((corner: Point) => {
80 | return orientation
81 | .pixels(new Point(this.x, this.y), corner.x, corner.y)
82 | .toString();
83 | })
84 | .join(" ");
85 |
86 | svgEl.createSvg("polygon", {
87 | attr: {
88 | ...attributes,
89 | id: this.namespace(this.id),
90 | points,
91 | },
92 | });
93 | }
94 |
95 | svgLabel(
96 | svgEl: SVGElement,
97 | orientation: Orientation,
98 | labelAttributes: any,
99 | glowAttributes: any
100 | ): void {
101 | if (this.label === undefined) {
102 | return;
103 | }
104 | const attributes = {
105 | ...labelAttributes,
106 | };
107 |
108 | // Computing the label and link
109 | const textContent =
110 | this.computeLinkAndLabel(this.label).length > 1
111 | ? this.computeLinkAndLabel(this.label)[1]
112 | : this.computeLinkAndLabel(this.label)[0];
113 | const linkContent = this.computeLinkAndLabel(this.label)[0];
114 |
115 | if (this.size !== undefined) {
116 | attributes["font-size"] = this.size;
117 | }
118 | const pix = orientation.pixels(
119 | new Point(this.x, this.y),
120 | 0,
121 | orientation.dy * orientation.labelOffset
122 | );
123 | const gEl = svgEl.createSvg("g");
124 |
125 | const glowEl = gEl.createSvg("text", {
126 | attr: {
127 | "text-anchor": "middle",
128 | x: pix.x.toFixed(1),
129 | y: pix.y.toFixed(1),
130 | ...attributes,
131 | ...glowAttributes,
132 | },
133 | });
134 | glowEl.textContent = textContent;
135 |
136 | //Only create a link if there is one.
137 | if (textContent !== linkContent) {
138 | //Add in clickable link for Obsidian
139 | const labelLinkEl = gEl.createSvg("a", {
140 | attr: {
141 | "data-tooltip-position": "top",
142 | "aria-label": linkContent,
143 | href: linkContent,
144 | "data-href": linkContent,
145 | class: "internal-link",
146 | target: "_blank",
147 | rel: "noopener",
148 | },
149 | });
150 |
151 | const labelEl = labelLinkEl.createSvg("text", {
152 | attr: {
153 | "text-anchor": "middle",
154 | x: pix.x.toFixed(1),
155 | y: pix.y.toFixed(1),
156 | ...attributes,
157 | },
158 | });
159 |
160 | labelEl.textContent = textContent;
161 | } else {
162 | const labelEl = gEl.createSvg("text", {
163 | attr: {
164 | "text-anchor": "middle",
165 | x: pix.x.toFixed(1),
166 | y: pix.y.toFixed(1),
167 | ...attributes,
168 | },
169 | });
170 |
171 | labelEl.textContent = textContent;
172 | }
173 | }
174 |
175 | computeLinkAndLabel(label: string): [string, string] {
176 | let link = label;
177 | let display = label;
178 | if (label.includes("|")) {
179 | const parts = label.split("|");
180 | link = parts[0];
181 | display = parts[1];
182 | }
183 | return [link, display];
184 | }
185 | }
186 |
--------------------------------------------------------------------------------
/src/spline.ts:
--------------------------------------------------------------------------------
1 | import { SVGElement } from "./constants";
2 | import { Point, Orientation } from "./orientation";
3 |
4 | export class Spline {
5 | types: string;
6 | label: string;
7 | side: string;
8 | start: string;
9 | id: string;
10 | points: Point[];
11 | orientation: Orientation;
12 |
13 | constructor() {
14 | this.points = [];
15 | }
16 |
17 | addPoint(x: string, y: string) {
18 | const nX = parseInt(x);
19 | const nY = parseInt(y);
20 | this.points.push(new Point(nX, nY));
21 | }
22 |
23 | computeMissingPoints(): Point[] {
24 | let i = 0;
25 | let current = this.points[i++];
26 | const result = [current];
27 | while (i < this.points.length) {
28 | current = this.oneStep(current, this.points[i]);
29 | result.push(current);
30 | if (
31 | current.x == this.points[i].x &&
32 | current.y == this.points[i].y
33 | ) {
34 | i++;
35 | }
36 | }
37 | return result;
38 | }
39 |
40 | oneStep(from: Point, to: Point): Point {
41 | // Brute forcing the "next" step by trying all the neighbors. The
42 | // connection data to connect to neighboring hexes.
43 | //
44 | // Example Map Index for the array
45 | //
46 | // 0201 2
47 | // 0102 0302 1 3
48 | // 0202 0402
49 | // 0103 0303 6 4
50 | // 0203 0403 5
51 | // 0104 0304
52 | //
53 | // Note that the arithmetic changes when x is odd.
54 | // If this.orientation.swapEvenOdd === true, then this is the example map:
55 | //
56 | // Example Map Index for the array
57 | //
58 | // 0201 2
59 | // 0101 0301 1 3
60 | // 0202 0402
61 | // 0102 0302 6 4
62 | // 0203 0403 5
63 | // 0103 0303
64 | //
65 | // We need to use a different algorithm with horizontal hexes
66 | // Example map: Index for the array
67 | // 0301 0401 1 2
68 | // 0202 0302 0402 6 3
69 | // 0303 0403 0503 5 4
70 | // 0204 0304 0404
71 | //
72 | // If this.orientation.swapEvenOdd === true, then this is the example map:
73 | //
74 | // Example Map Index for the array
75 | // 0201 0301 1 2
76 | // 0202 0302 0402 6 3
77 | // 0203 0303 0403 5 4
78 | // 0204 0304 0404
79 | let delta;
80 | const evenOdd = this.orientation.swapEvenOdd ? 1 : 0;
81 |
82 | if (this.orientation.flatTop) {
83 | delta = [
84 | [
85 | new Point(-1, 0 - evenOdd), // -1 -1
86 | new Point(0, -1), // 0 -1
87 | new Point(+1, 0 - evenOdd), // +1 -1
88 | new Point(+1, +1 - evenOdd), // +1 +0
89 | new Point(0, +1), // 0 +1
90 | new Point(-1, +1 - evenOdd), // -1, 0
91 | ], // x is even
92 | [
93 | new Point(-1, -1 + evenOdd),
94 | new Point(0, -1),
95 | new Point(+1, -1 + evenOdd),
96 | new Point(+1, 0 + evenOdd),
97 | new Point(0, +1),
98 | new Point(-1, 0 + evenOdd),
99 | ], // x is odd
100 | ];
101 | } else {
102 | delta = [
103 | [
104 | new Point(0 - evenOdd, -1),
105 | new Point(1 - evenOdd, -1),
106 | new Point(+1, 0),
107 | new Point(+1 - evenOdd, +1),
108 | new Point(0 - evenOdd, +1),
109 | new Point(-1, 0),
110 | ], // Y is even
111 | [
112 | new Point(-1 + evenOdd, -1),
113 | new Point(0 + evenOdd, -1),
114 | new Point(+1, 0),
115 | new Point(0 + evenOdd, +1),
116 | new Point(-1 + evenOdd, +1),
117 | new Point(-1, 0),
118 | ], // Y is odd
119 | ];
120 | }
121 |
122 | let min, best;
123 |
124 | for (let i = 0; i < 6; i++) {
125 | // make a new guess
126 | let offset;
127 | if (this.orientation.flatTop) {
128 | offset = Math.abs(from.x % 2);
129 | } else {
130 | offset = Math.abs(from.y % 2);
131 | }
132 |
133 | const x = from.x + delta[offset][i].x;
134 | const y = from.y + delta[offset][i].y;
135 | let d = (to.x - x) * (to.x - x) + (to.y - y) * (to.y - y);
136 | if (min === undefined || d < min) {
137 | min = d;
138 | best = new Point(x, y);
139 | }
140 | }
141 |
142 | return best;
143 | }
144 |
145 | partway(from: Point, to: Point, lerp: number = 1): Point {
146 | const pix1 = this.orientation.pixels(from);
147 | const pix2 = this.orientation.pixels(to);
148 | return new Point(
149 | pix1.x + (pix2.x - pix1.x) * lerp,
150 | pix1.y + (pix2.y - pix1.y) * lerp
151 | );
152 | }
153 |
154 | svg(svgEl: SVGElement, orientation: any, pathAttributes: any): void {
155 | this.orientation = orientation;
156 | const points = this.computeMissingPoints();
157 | let closed = false;
158 | if (points.length == 0) {
159 | return;
160 | }
161 |
162 | if (points[0].eq(points[points.length - 1])) {
163 | closed = true;
164 | }
165 |
166 | let path = "";
167 |
168 | if (closed) {
169 | for (let i = 0; i < points.length - 1; i++) {
170 | let current = points[i];
171 | let next = points[i + 1];
172 | if (path.length === 0) {
173 | let a = this.partway(current, next, 0.3).toString();
174 | let b = this.partway(current, next, 0.5).toString();
175 | let c = this.partway(
176 | points[points.length - 1],
177 | current,
178 | 0.7
179 | ).toString();
180 | let d = this.partway(
181 | points[points.length - 1],
182 | current,
183 | 0.5
184 | ).toString();
185 | path += `M${d} C${c} ${a} ${b}`;
186 | } else {
187 | // continue curve
188 | let b = this.partway(current, next, 0.5).toString();
189 | let a = this.partway(current, next, 0.3).toString();
190 | path += ` S${a} ${b}`;
191 | }
192 | }
193 | } else {
194 | let current, next;
195 | for (let i = 0; i < points.length - 1; i++) {
196 | current = points[i];
197 | next = points[i + 1];
198 | if (path.length === 0) {
199 | // line from a to b; control point a required for following S commands
200 | let a = this.partway(current, next, 0.3).toString();
201 | let b = this.partway(current, next, 0.5).toString();
202 | path += `M${a} C${b} ${a} ${b}`;
203 | } else {
204 | // continue curve
205 | let a = this.partway(current, next, 0.3).toString();
206 | let b = this.partway(current, next, 0.5).toString();
207 | path += ` S${a} ${b}`;
208 | }
209 | }
210 | // end with a little stub
211 | path += " L" + this.partway(current, next, 0.7).toString();
212 | }
213 |
214 | svgEl.createSvg("path", {
215 | attr: {
216 | id: this.id,
217 | type: this.types,
218 | ...pathAttributes[this.types],
219 | d: path,
220 | },
221 | });
222 | }
223 |
224 | svgLabel(
225 | svgEl: SVGElement,
226 | labelAttributes: any,
227 | glowAttributes: any
228 | ): void {
229 | if (this.label === undefined) {
230 | return;
231 | }
232 | const points = this.computeMissingPoints();
233 | const pathAttributes: any = {
234 | href: `#${this.id}`,
235 | };
236 | // Default side is left, but if the line goes from right to left, then "left"
237 | // means "upside down", so allow people to control it.
238 | if (this.side !== undefined) {
239 | pathAttributes["side"] = this.side;
240 | } else if (
241 | points[1].x < points[0].x ||
242 | (points.length > 2 && points[2].x < points[0].x)
243 | ) {
244 | pathAttributes["side"] = "right";
245 | }
246 | if (this.start !== undefined) {
247 | pathAttributes["startOffset"] = this.start;
248 | }
249 |
250 | const gEl = svgEl.createSvg("g");
251 | const glowEl = gEl.createSvg("text", {
252 | attr: {
253 | ...labelAttributes,
254 | ...glowAttributes,
255 | },
256 | });
257 | const glowPathEl = glowEl.createSvg("textPath", {
258 | attr: pathAttributes,
259 | });
260 | glowPathEl.textContent = this.label;
261 |
262 | const labelEl = gEl.createSvg("text", { attr: labelAttributes });
263 | const labelPathEl = labelEl.createSvg("textPath", {
264 | attr: pathAttributes,
265 | });
266 | labelPathEl.textContent = this.label;
267 | }
268 | }
269 |
--------------------------------------------------------------------------------
/styles.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/modality/obsidian-text-mapper/5d5af1fdf1eecc655353c612a0142dd911c34dab/styles.css
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "inlineSourceMap": true,
5 | "inlineSources": true,
6 | "module": "ESNext",
7 | "target": "ESNext",
8 | "allowJs": true,
9 | "noImplicitAny": true,
10 | "moduleResolution": "node",
11 | "importHelpers": true,
12 | "lib": [
13 | "DOM",
14 | "ES5",
15 | "ES6",
16 | "ES7"
17 | ]
18 | },
19 | "include": [
20 | "**/*.ts"
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/versions.json:
--------------------------------------------------------------------------------
1 | {
2 | "1.0.0": "0.12.0"
3 | }
4 |
--------------------------------------------------------------------------------