├── .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 | example 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 | Very Large Alpine Map 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 | example render 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 | --------------------------------------------------------------------------------