├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── docs ├── deps.typ ├── manual.typ ├── schemas.typ └── vendor │ ├── README.md │ ├── mantys.typ │ ├── mantys │ └── types.typ │ ├── valkyrie.typ │ └── valkyrie │ └── types.typ ├── justfile ├── scripts └── package.sh ├── src ├── backends │ ├── mod.typ │ └── std.typ ├── components │ ├── common.typ │ ├── mod.typ │ └── std.typ ├── core │ ├── anchor.typ │ ├── dir.typ │ ├── feature.typ │ ├── mod.typ │ ├── utils.typ │ └── vec.typ ├── deps.typ ├── elem │ ├── line.typ │ ├── metro.typ │ ├── mod.typ │ ├── radish.typ │ ├── shapes.typ │ └── station.typ ├── lib.typ └── radishom.typ └── typst.toml /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test, Build, and Release 2 | on: 3 | push: 4 | branches: [main] 5 | tags: ["v*"] 6 | pull_request: 7 | branches: [main] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | pre_build: 12 | permissions: 13 | actions: write 14 | contents: read 15 | name: Duplicate Actions Detection 16 | runs-on: ubuntu-latest 17 | outputs: 18 | should_skip: ${{ steps.skip_check.outputs.should_skip }} 19 | steps: 20 | - id: skip_check 21 | uses: fkirc/skip-duplicate-actions@v5 22 | with: 23 | cancel_others: "true" 24 | 25 | # test: 26 | # needs: [pre_build] 27 | # runs-on: ubuntu-latest 28 | # steps: 29 | # - name: Checkout 30 | # uses: actions/checkout@v4 31 | 32 | # - name: Install tools 33 | # uses: taiki-e/install-action@v2 34 | # with: 35 | # tool: cargo-binstall,just 36 | # - name: Install more tools 37 | # run: | 38 | # cargo binstall tytanic -y 39 | 40 | # - name: Run test suite 41 | # run: just test 42 | 43 | build: 44 | needs: [pre_build] 45 | runs-on: ubuntu-latest 46 | steps: 47 | - name: Checkout 48 | uses: actions/checkout@v4 49 | 50 | - name: Install tools 51 | uses: taiki-e/install-action@just 52 | 53 | - name: Setup typst 54 | uses: typst-community/setup-typst@v3 55 | 56 | - name: Build package 57 | run: | 58 | just doc 59 | just package out 60 | 61 | - name: Upload artifacts 62 | uses: actions/upload-artifact@v4 63 | with: 64 | name: radishom 65 | path: out/**/* 66 | 67 | release: 68 | runs-on: ubuntu-latest 69 | needs: [build] 70 | if: success() && startsWith(github.ref, 'refs/tags/') 71 | permissions: 72 | contents: write 73 | steps: 74 | - uses: actions/checkout@v4 75 | with: 76 | submodules: recursive 77 | - uses: actions/download-artifact@v4 78 | with: 79 | path: artifacts 80 | - name: Display structure of downloaded files 81 | run: ls -R artifacts 82 | - uses: ncipollo/release-action@v1 83 | with: 84 | token: ${{ secrets.GITHUB_TOKEN }} 85 | artifacts: "artifacts/*/*" 86 | allowUpdates: true 87 | omitBodyDuringUpdate: true 88 | omitDraftDuringUpdate: true 89 | omitNameDuringUpdate: true 90 | omitPrereleaseDuringUpdate: true 91 | bodyFile: CHANGELOG.md 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /out 2 | /target 3 | 4 | *.pdf 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 QuadnucYard 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 | # radishom🚇️ 2 | 3 | Draw elegant metro maps with ease. 4 | 5 | > radishom = RADISH + blossOm + Metro 6 | > 7 | > I am a metro fan in Nanjing (南京), whose metro (Nanjing Metro, 南京地铁) is often affectionately referred to as _Radish Metro_ (萝铁) in the fork, so I adopt this name. May it blossom one day! 8 | 9 | ## Examples 10 | 11 | For the GREAT example, see [radish-metro](https://github.com/QuadnucYard/radish-metro). 12 | 13 | ## Usage 14 | 15 | To be documented later. 16 | -------------------------------------------------------------------------------- /docs/deps.typ: -------------------------------------------------------------------------------- 1 | #import "@preview/mantys:1.0.1" 2 | #import "@preview/valkyrie:0.2.2" 3 | -------------------------------------------------------------------------------- /docs/manual.typ: -------------------------------------------------------------------------------- 1 | #import "vendor/valkyrie.typ": valkyrie as z 2 | #import "vendor/mantys.typ": * 3 | 4 | #import "../src/lib.typ" as radishom 5 | #import "schemas.typ" as s 6 | 7 | #let show-module(name, scope: (:), ..tidy-args) = tidy-module( 8 | name, 9 | read("../src/" + name + ".typ"), 10 | scope: scope, 11 | // Some defaults you want to set 12 | legacy-parser: true, 13 | ..tidy-args.named(), 14 | ) 15 | 16 | #show: mantys( 17 | // abstract: [ 18 | // A few paragraphs of text to describe the package. 19 | // ], 20 | 21 | examples-scope: ( 22 | scope: (radishom: radishom), 23 | imports: (radishom: "*"), 24 | ), 25 | 26 | ..toml("../typst.toml"), 27 | ) 28 | 29 | 30 | = Quick Start 31 | 32 | 33 | = Usage 34 | 35 | 36 | = Schemas 37 | 38 | #info-alert[ 39 | Some automatic fields are set to a concrete value after certain stages, which are specified after the schemas. 40 | 41 | The typing displayed in the schemas are after @cmd:radish. 42 | ] 43 | 44 | #frame(title: [@type:vec2])[ 45 | #schema(auto, s.vec2) 46 | ] 47 | 48 | #frame(title: [@type:dir])[ 49 | #schema("dir", s.dir, expand-choices: 8) 50 | ] 51 | 52 | #frame(title: [@type:segment])[ 53 | #schema(auto, s.segment, child-schemas: ("vec2",)) 54 | 55 | #info-alert[ 56 | Field values determined after @cmd:radish: `disabled`. 57 | ] 58 | ] 59 | 60 | #frame(title: [@type:section])[ 61 | #schema(auto, s.section, child-schemas: ("vec2",)) 62 | 63 | #info-alert[ 64 | Field values determined after @cmd:radish: `disabled`. 65 | ] 66 | ] 67 | 68 | #frame(title: [@type:station])[ 69 | #schema(auto, s.station, child-schemas: ("vec2", "dir")) 70 | 71 | #info-alert[ 72 | Possible #dtype(auto) values before @cmd:metro: `pos`. 73 | 74 | Possible #dtype(auto) values before @cmd:radish: `anchor`. 75 | 76 | Field values determined after @cmd:radish: `disabled`, `terminal` (either undefined or `true`). 77 | ] 78 | ] 79 | 80 | #frame(title: [@type:line])[ 81 | #schema(auto, s.line, child-schemas: ("vec2", "segment", "section", "station")) 82 | 83 | #info-alert[ 84 | Field values determined after @cmd:radish: `disabled`. 85 | ] 86 | ] 87 | 88 | #frame(title: [@type:metro])[ 89 | #schema(auto, s.metro, child-schemas: ("line",)) 90 | ] 91 | 92 | #frame(title: [@type:radish])[ 93 | #schema(auto, s.radish, child-schemas: ("line",)) 94 | ] 95 | 96 | 97 | = API Reference 98 | 99 | #let show-module = show-module.with( 100 | show-outline: false, 101 | omit-private-definitions: true, 102 | omit-private-parameters: true, 103 | sort-functions: false, 104 | ) 105 | 106 | == Metro Elements 107 | 108 | #show-module("elem/station") 109 | #show-module("elem/line") 110 | #show-module("elem/metro") 111 | #show-module("elem/radish") 112 | #show-module("radishom") 113 | 114 | == Shapes 115 | 116 | #show-module("elem/shapes") 117 | 118 | == Utilities 119 | 120 | #let q-import(path) = { 121 | import path as mod 122 | dictionary(mod) 123 | } 124 | #let show-module-(path) = { 125 | show-module(path, scope: q-import("/src/" + path + ".typ")) 126 | } 127 | 128 | #show-module-("core/dir") 129 | #show-module-("core/anchor") 130 | #show-module-("core/feature") 131 | // show-module("core/vec") 132 | #show-module-("core/utils") 133 | -------------------------------------------------------------------------------- /docs/schemas.typ: -------------------------------------------------------------------------------- 1 | 2 | #import "vendor/valkyrie.typ" as z 3 | 4 | #let optional(ty) = { 5 | ty.optional = true 6 | ty 7 | } 8 | 9 | #let nullable(ty) = { 10 | z.either(ty, z.base-type(name: "none")) 11 | } 12 | 13 | #let smart(ty) = { 14 | z.either(z.base-type(name: "auto"), ty) 15 | } 16 | 17 | #let vec2 = z.tuple(name: "vec2", z.integer(), z.integer()) 18 | 19 | #let dir = z.choice( 20 | name: "dir", 21 | ("north", "south", "west", "east", "north-west", "north-east", "south-west", "south-east"), 22 | ) 23 | 24 | #let section = z.dictionary( 25 | name: "section", 26 | ( 27 | points: z.array(vec2), 28 | cfg: nullable(z.string()), 29 | cfg-not: nullable(z.string()), 30 | layer: z.number(), 31 | stroke: smart(z.stroke()), 32 | disabled: z.boolean(), 33 | metadata: z.dictionary((:)), 34 | ), 35 | ) 36 | 37 | #let segment = z.dictionary( 38 | name: "segment", 39 | ( 40 | start: vec2, 41 | end: vec2, 42 | angle: z.angle(), 43 | range: z.dictionary((start: z.integer(), end: z.integer())), 44 | cfg: nullable(z.string()), 45 | cfg-not: nullable(z.string()), 46 | disabled: z.boolean(), 47 | ), 48 | ) 49 | 50 | #let station = z.dictionary( 51 | name: "station", 52 | ( 53 | id: z.string(), 54 | name: z.any(), 55 | anchor: dir, 56 | transfer: nullable(z.base-type(name: "auto")), 57 | pos: vec2, 58 | segment: z.integer(), 59 | line: z.string(), 60 | hidden: optional(z.literals(true)), 61 | branch: optional(z.literals(true)), 62 | disabled: z.literals(true), 63 | marker-pos: optional(vec2), 64 | marker-offset: optional(vec2), 65 | label-pos: optional(vec2), 66 | label-offset: optional(vec2), 67 | cfg: z.string(optional: true), 68 | cfg-not: z.string(optional: true), 69 | terminal: optional(z.literals(true)), 70 | metadata: z.base-type(name: "arguments"), 71 | ), 72 | ) 73 | 74 | #let line = z.dictionary( 75 | name: "line", 76 | ( 77 | id: z.string(), 78 | color: z.color(), 79 | index: z.integer(), 80 | sections: z.array(section), 81 | segments: z.array(segment), 82 | stations: z.array(station), 83 | ordered-stations: z.array(z.string()), 84 | station-indexer: z.mapping(z.integer(), key-name: "station-id"), 85 | optional: z.boolean(), 86 | features: z.mapping(z.array(z.string())), 87 | default-features: z.array(z.string()), 88 | stroke: optional(z.stroke()), 89 | disabled: z.boolean(), 90 | metadata: z.dictionary((:)), 91 | ), 92 | ) 93 | 94 | #let metro = z.dictionary( 95 | name: "metro", 96 | ( 97 | lines: z.mapping(line, key-name: "line-id"), 98 | transfers: z.mapping(z.array(z.string()), key-name: "station-name"), 99 | features: z.mapping(z.array(z.string())), 100 | default-features: z.array(z.string()), 101 | ), 102 | ) 103 | 104 | #let radish = z.dictionary( 105 | name: "radish", 106 | ( 107 | lines: z.mapping(line, key-name: "line-id"), 108 | transfers: z.mapping(z.array(z.string()), key-name: "station-name"), 109 | enabled-transfers: z.mapping(z.array(z.string()), key-name: "station-name"), 110 | features: z.mapping(z.array(z.string())), 111 | default-features: z.array(z.string()), 112 | ), 113 | ) 114 | -------------------------------------------------------------------------------- /docs/vendor/README.md: -------------------------------------------------------------------------------- 1 | # Vendors for Documentation 2 | 3 | Here we patches the `valkyrie` and `mantys` package to support more features. 4 | -------------------------------------------------------------------------------- /docs/vendor/mantys.typ: -------------------------------------------------------------------------------- 1 | #import "../deps.typ": mantys 2 | #import mantys: * 3 | #import "./mantys/types.typ": dtype, schema 4 | -------------------------------------------------------------------------------- /docs/vendor/mantys/types.typ: -------------------------------------------------------------------------------- 1 | #import "../../deps.typ": mantys 2 | #import mantys: ( 3 | is as is_, 4 | links, 5 | styles, 6 | values, 7 | custom-type, 8 | is-custom-type, 9 | link-custom-type, 10 | type-box, 11 | _type-aliases, 12 | _type-map, 13 | ) 14 | 15 | 16 | /// Dictionary of builtin types, mapping the types name to its actual type. 17 | #let _type-map = ( 18 | "auto": auto, 19 | "none": none, 20 | // foundations 21 | arguments: arguments, 22 | array: array, 23 | bool: bool, 24 | bytes: bytes, 25 | content: content, 26 | datetime: datetime, 27 | dictionary: dictionary, 28 | float: float, 29 | function: function, 30 | int: int, 31 | location: location, 32 | module: module, 33 | plugin: plugin, 34 | regex: regex, 35 | selector: selector, 36 | str: str, 37 | type: type, 38 | label: label, 39 | version: version, 40 | // layout 41 | alignment: alignment, 42 | angle: angle, 43 | direction: direction, 44 | fraction: fraction, 45 | length: length, 46 | ratio: ratio, 47 | relative: relative, 48 | // visualize 49 | color: color, 50 | gradient: gradient, 51 | stroke: stroke, 52 | // extension 53 | number: float, 54 | ) 55 | 56 | /// Dictionary of colors to use for builtin types. 57 | /// 58 | /// Modified to keep consistent with official docs. 59 | #let _type-colors = { 60 | let red = rgb("#ffcbc4") // for keyword 61 | let gray = rgb("#eff0f3") // for special content 62 | let pink = rgb("#f9dfff") // for data structure 63 | let yellow = rgb("#ffedc1") // for arithmetic 64 | let purple = rgb("#d1d4fd") // for type 65 | let green = rgb("#d1ffe2") // for string-like 66 | let blue = rgb("#c6d6ec") 67 | let cyan = rgb("#a6eaff") // for enum 68 | let rainbow = gradient.linear( 69 | (rgb("#7cd5ff"), 0%), 70 | (rgb("#a6fbca"), 33%), 71 | (rgb("#fff37c"), 66%), 72 | (rgb("#ffa49d"), 100%), 73 | ) // for color-like 74 | let changeable = gradient.linear( 75 | (rgb("#a07aaa"), 0%), 76 | (rgb("#a6aff6"), 28%), 77 | (rgb("#89c8e5"), 50%), 78 | (rgb("#b7daec"), 72%), 79 | (rgb("#dcac68"), 100%), 80 | ) // for time-like 81 | 82 | ( 83 | // fallback 84 | default: gray, 85 | custom: rgb("#fcfdb7"), 86 | // special 87 | any: gray, 88 | // foundations 89 | arguments: pink, 90 | array: pink, 91 | "auto": red, 92 | bool: yellow, 93 | bytes: pink, 94 | content: rgb("#a6ebe6"), 95 | datetime: changeable, 96 | decimal: yellow, 97 | dictionary: pink, 98 | duration: changeable, 99 | float: yellow, 100 | function: blue, 101 | int: yellow, 102 | label: blue, 103 | module: blue, 104 | "none": red, 105 | plugin: blue, // not really a type 106 | regex: green, 107 | selector: blue, 108 | str: green, 109 | string: green, 110 | symbol: green, 111 | type: purple, 112 | version: pink, 113 | // layout 114 | alignment: cyan, 115 | angle: yellow, 116 | direction: cyan, 117 | fraction: yellow, 118 | length: yellow, 119 | ratio: yellow, 120 | relative: yellow, 121 | // visualize 122 | color: rainbow, 123 | gradient: rainbow, 124 | stroke: rainbow, 125 | tiling: gradient.linear(rgb("#ffd2ec"), rgb("#c6feff"), angle: -16deg).sharp(2).repeat(5), // approximation 126 | // introspection 127 | counter: gray, 128 | location: blue, 129 | state: gray, 130 | ) 131 | } 132 | 133 | /// Displays a type link to the type #arg[name]. #arg[name] can 134 | /// either be a #link(, "builtin type") or a registered @type:custom-type. 135 | /// 136 | /// Builtin types are linked to the official Typst reference documentation. Custom types to their location in the manual. 137 | /// Some builtin types can be referenced by aliases like `dict` for `dictionary`. 138 | /// 139 | /// If #arg[name] is given as a #typ.t.str it is taken as the name of the type. If #arg[name] is a #typ.type or any other value, the type of the value is displayed. 140 | /// 141 | /// - #ex(`#dtype("string")`) 142 | /// - #ex(`#dtype("dict")`) 143 | /// - #ex(`#dtype(1.0)`) 144 | /// - #ex(`#dtype(true)`) 145 | /// - #ex(`#dtype("document")`) 146 | /// -> content 147 | #let dtype( 148 | /// Name of the type. 149 | /// -> any 150 | name, 151 | /// If the type should be linked to the Typst documentation or the location of the custom type. 152 | /// Set to #typ.v.false to disable linking. 153 | /// -> bool 154 | link: true, 155 | ) = context { 156 | // TODO: (jneug) parse types like "array[str]" 157 | let _type 158 | 159 | if is_.type(name) or is_._auto(name) or is_._none(name) { 160 | _type = name 161 | } else if not is_.str(name) { 162 | _type = type(name) 163 | } else { 164 | let name = _type-aliases.at(name, default: name) 165 | if name in _type-map { 166 | _type = _type-map.at(name) 167 | } else if is-custom-type(name) { 168 | return link-custom-type(name) 169 | } else { 170 | return links.link-dtype(name, type-box(name, _type-colors.default)) 171 | } 172 | } 173 | 174 | _type = repr(_type) 175 | return links.link-dtype(_type, type-box(_type, _type-colors.at(_type))) 176 | } 177 | 178 | 179 | /// Change: 180 | /// - support `tuple`. 181 | /// - make child-schemas more flexible. 182 | #let parse-schema( 183 | schema, 184 | expand-schemas: false, 185 | expand-choices: 2, 186 | child-schemas: (), 187 | // Passing in dtype and value to avoid circular imports 188 | _dtype: none, 189 | _value: none, 190 | ) = { 191 | let el = schema 192 | 193 | if schema.name in child-schemas { 194 | return _dtype(schema.name) 195 | } 196 | 197 | // TODO: implement expand-schemas and child-schemas options 198 | let options = ( 199 | expand-schemas: expand-schemas, 200 | expand-choices: expand-choices, 201 | _dtype: _dtype, 202 | _value: _value, 203 | ) 204 | 205 | // Recursivley handle dictionaries 206 | if "dictionary-schema" in el { 207 | if el.dictionary-schema == (:) { 208 | _dtype(dictionary) 209 | } else { 210 | show terms: set block(below: 0.6em) 211 | let inner = terms( 212 | hanging-indent: 1.28em, 213 | indent: .64em, 214 | ..for (key, el) in el.dictionary-schema { 215 | ( 216 | terms.item( 217 | styles.arg(key) 218 | + if el.optional { 219 | if el.default == none { 220 | `?` 221 | } else { 222 | `: ` + raw(lang: "typc", repr(el.default)) 223 | } 224 | }, 225 | parse-schema(el, child-schemas: child-schemas, ..options), 226 | ), 227 | ) 228 | }, 229 | ) 230 | [`(`#inner`)`] 231 | } 232 | } else if "descendents-schema" in el { 233 | let inner = parse-schema( 234 | el.descendents-schema, 235 | child-schemas: child-schemas, 236 | ..options, 237 | ) 238 | [#_dtype(array) of #inner] 239 | } else if "choices" in el { 240 | let inner = if expand-choices in (false, 0) { 241 | [#sym.dots] 242 | } else if type(expand-choices) == int and el.choices.len() > expand-choices { 243 | el.choices.slice(0, expand-choices).map(_value).join(", ") + [ #sym.dots] 244 | } else { 245 | el.choices.map(_value).join(", ") 246 | } 247 | [one of `(`#inner`)`] 248 | } else if "options" in el { 249 | let inner = el 250 | .options 251 | .map(el => parse-schema( 252 | el, 253 | child-schemas: child-schemas, 254 | ..options, 255 | )) 256 | .join[`|`] 257 | inner 258 | } else if "tuple-schema" in el { 259 | let inner = el 260 | .tuple-schema 261 | .map(el => parse-schema( 262 | el, 263 | child-schemas: child-schemas, 264 | ..options, 265 | )) 266 | .join[`,`] 267 | [tuple of `(`#inner`)`] 268 | } else if "value-schema" in el { 269 | let key-repr = if el.key-name != none { el.key-name } else { _dtype(str) } 270 | let inner = parse-schema( 271 | el.value-schema, 272 | child-schemas: child-schemas, 273 | ..options, 274 | ) 275 | [mapping from #key-repr to #inner] 276 | } else if "literals" in el { 277 | let inner = el.literals.map(lit => raw(lang: "typc", repr(lit))).join[`|`] 278 | inner 279 | } else { 280 | // polyfill: `int` and `float` have the same name `number` in valkyrie 281 | if el.description == "integer" { 282 | _dtype(int) 283 | } else if el.description == "float" { 284 | _dtype(float) 285 | } else { 286 | _dtype(el.name) 287 | } 288 | } 289 | } 290 | 291 | 292 | #let schema(name, definition, color: auto, ..args) = { 293 | assert(is_.dict(definition)) 294 | assert("valkyrie-type" in definition) 295 | 296 | custom-type(if name == auto { definition.name } else { name }, color: color) 297 | 298 | parse-schema(definition, ..args, _dtype: dtype, _value: values.value) 299 | } 300 | -------------------------------------------------------------------------------- /docs/vendor/valkyrie.typ: -------------------------------------------------------------------------------- 1 | #import "../deps.typ": valkyrie 2 | #import valkyrie: * 3 | #import "./valkyrie/types.typ": literals, mapping 4 | -------------------------------------------------------------------------------- /docs/vendor/valkyrie/types.typ: -------------------------------------------------------------------------------- 1 | #import "../../deps.typ": valkyrie 2 | #import valkyrie: base-type, z-ctx, advanced, one-of 3 | #import advanced: * 4 | 5 | #let dictionary-type = type((:)) 6 | 7 | #let literals( 8 | assertions: (), 9 | default: (:), 10 | ..args, 11 | ) = { 12 | let values = args.pos() 13 | ( 14 | base-type( 15 | name: "literals", 16 | assertions: (one-of(values), ..assertions), 17 | ..args.named(), 18 | ) 19 | + ( 20 | literals: values, 21 | ) 22 | ) 23 | } 24 | 25 | /// Valkyrie schema generator for mapping types. 26 | /// 27 | /// -> schema 28 | #let mapping( 29 | value-schema, 30 | name: "mapping", 31 | key-name: none, 32 | default: (:), 33 | pre-transform: (self, it) => it, 34 | ..args, 35 | ) = { 36 | ( 37 | base-type( 38 | name: name, 39 | default: default, 40 | types: (dictionary-type,), 41 | pre-transform: pre-transform, 42 | ..args.named(), 43 | ) 44 | + ( 45 | key-name: key-name, 46 | value-schema: value-schema, 47 | handle-descendents: (self, it, ctx: z-ctx(), scope: ()) => { 48 | if (it.len() == 0 and self.optional) { 49 | return none 50 | } 51 | 52 | for (key, schema) in self.value-schema { 53 | let entry = ( 54 | schema.validate 55 | )( 56 | schema, 57 | it.at(key, default: none), // implicitly handles missing entries 58 | ctx: ctx, 59 | scope: (..scope, str(key)), 60 | ) 61 | 62 | if (entry != none or ctx.remove-optional-none == false) { 63 | it.insert(key, entry) 64 | } 65 | } 66 | return it 67 | }, 68 | ) 69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | export TYPST_ROOT := justfile_directory() 2 | 3 | 4 | default: 5 | @just --list 6 | 7 | doc: 8 | typst c docs/manual.typ -f pdf 9 | 10 | # test: 11 | # tt run --no-fail-fast 12 | 13 | # package the library into the specified destination folder 14 | package target="out": 15 | ./scripts/package.sh "{{target}}" 16 | -------------------------------------------------------------------------------- /scripts/package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check if target directory argument is provided 4 | if [ -z "$1" ]; then 5 | echo "Usage: $0 " 6 | exit 1 7 | fi 8 | 9 | TARGET_DIR="$1" 10 | 11 | 12 | # If the target directory exists, remove its contents; otherwise, create it. 13 | if [ -d "$TARGET_DIR" ]; then 14 | rm -rf "$TARGET_DIR"/* 15 | else 16 | mkdir -p "$TARGET_DIR" 17 | fi 18 | 19 | # Create a temporary directory for packaging 20 | TEMP_DIR=$(mktemp -d) || { echo "Failed to create temp directory"; exit 1; } 21 | 22 | # Copy files and directories into the temp directory 23 | cp --parents examples/*.svg "$TEMP_DIR" 2>/dev/null 24 | cp --parents -r src "$TEMP_DIR" 2>/dev/null 25 | cp --parents LICENSE "$TEMP_DIR" 2>/dev/null 26 | cp --parents README.md "$TEMP_DIR" 2>/dev/null 27 | cp --parents typst.toml "$TEMP_DIR" 2>/dev/null 28 | 29 | # Create a 7z archive of the temporary directory. 30 | # Archive name will be based on the temporary directory name. 31 | ARCHIVE_NAME="$TARGET_DIR/radishom.7z" 32 | 33 | 7z a "$ARCHIVE_NAME" "$TEMP_DIR"/* 34 | 35 | # Remove the temporary directory 36 | rm -rf "$TEMP_DIR" 37 | 38 | # Copy docs/manual.pdf directly to the target directory 39 | cp docs/manual.pdf "$TARGET_DIR" 2>/dev/null 40 | 41 | echo "Packaging complete. Archive saved at $ARCHIVE_NAME" 42 | -------------------------------------------------------------------------------- /src/backends/mod.typ: -------------------------------------------------------------------------------- 1 | 2 | #let use(backend) = { 3 | if backend == "std" { 4 | import "std.typ" as comp 5 | comp 6 | } else { 7 | panic("unknown backend: ", backend) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/backends/std.typ: -------------------------------------------------------------------------------- 1 | #import "../core/dir.typ": dirs 2 | 3 | 4 | #let _zero-pos = (0, 0) 5 | 6 | #let _get-anchor-align(anchor) = { 7 | let h-align = if "west" in anchor { left } else if "east" in anchor { right } else { center } 8 | let v-align = if "north" in anchor { top } else if "south" in anchor { bottom } else { horizon } 9 | h-align + v-align 10 | } 11 | 12 | #let _anchored(body, anchor) = { 13 | place(_get-anchor-align(anchor), body) 14 | } 15 | 16 | #let _get-canvas(coords, u, fill: white) = { 17 | let ((x1, y1), (x2, y2)) = coords 18 | body => block( 19 | move(body, dx: -x1 * u, dy: y2 * u), 20 | width: (x2 - x1) * u, 21 | height: (y2 - y1) * u, 22 | fill: fill, 23 | ) 24 | } 25 | 26 | /// Get the position on the canvas. 27 | #let _cpos(pos, u) = { 28 | (pos.at(0) * u, -pos.at(1) * u) 29 | } 30 | 31 | /// Place an element on the canvas with given position. 32 | #let _draw(element, pos, u) = { 33 | let (x, y) = pos 34 | place(element, dx: x * u, dy: y * -u) 35 | } 36 | 37 | #let _draw-grid(grid, u) = { 38 | let ((x1, y1), (x2, y2)) = grid.coords 39 | let pat = tiling(size: (u, u))[ 40 | #rect(width: u, height: u, stroke: grid.stroke) 41 | ] 42 | _draw(rect(fill: pat, width: 100%, height: 100%), (x1, y2), u) 43 | 44 | for x in range(calc.ceil(x1 / 5) * 5, calc.floor(x2 / 5) * 5 + 1, step: 5) { 45 | place(line(start: (x * u, -y1 * u), end: (x * u, -y2 * u), stroke: grid.heavy-stroke)) 46 | } 47 | for y in range(calc.ceil(y1 / 5) * 5, calc.floor(y2 / 5) * 5 + 1, step: 5) { 48 | place(line(start: (x1 * u, -y * u), end: (x2 * u, -y * u), stroke: grid.heavy-stroke)) 49 | } 50 | } 51 | 52 | /* Calculates a rounded corner between two line segments. 53 | * 54 | * Parameters: 55 | * - pt: (float, float) The corner point where the two lines meet 56 | * - p1: (float, float) The endpoint of the first line segment 57 | * - p2: (float, float) The endpoint of the second line segment 58 | * - radius: float The desired radius of the rounded corner 59 | * - u: float Scale factor for the output coordinates 60 | * 61 | * Returns: 62 | * A tuple containing two curve segments that form the rounded corner: 63 | * - A line segment from the first arc point 64 | * - A cubic Bézier curve connecting the two arc points 65 | * 66 | * The function clamps the radius to prevent it from exceeding half the length 67 | * of either line segment. It generates a smooth transition between the lines 68 | * using a combination of a straight line and a cubic Bézier curve. 69 | * The resulting coordinates are scaled by factor u, with y-coordinates inverted. 70 | */ 71 | #let _round-corner(pt, p1, p2, radius, u) = { 72 | // here we avoid func-call to improve performance 73 | let (x0, y0) = pt 74 | let (x1, y1) = p1 75 | let (x2, y2) = p2 76 | let d1 = calc.sqrt((x1 - x0) * (x1 - x0) + (y1 - y0) * (y1 - y0)) 77 | let d2 = calc.sqrt((x2 - x0) * (x2 - x0) + (y2 - y0) * (y2 - y0)) 78 | let radius = calc.min(radius, d1 / 2, d2 / 2) // clamp radius 79 | let arc-x1 = x0 + (x1 - x0) * radius / d1 // arc point 1 80 | let arc-y1 = y0 + (y1 - y0) * radius / d1 81 | let arc-x2 = x0 + (x2 - x0) * radius / d2 // arc point 2 82 | let arc-y2 = y0 + (y2 - y0) * radius / d2 83 | let ct-x1 = (x0 + arc-x1) * 0.5 84 | let ct-y1 = (y0 + arc-y1) * 0.5 85 | let ct-x2 = (x0 + arc-x2) * 0.5 86 | let ct-y2 = (y0 + arc-y2) * 0.5 87 | ( 88 | curve.line((arc-x1 * u, arc-y1 * -u)), 89 | curve.cubic((ct-x1 * u, ct-y1 * -u), (ct-x2 * u, ct-y2 * -u), (arc-x2 * u, arc-y2 * -u)), 90 | ) 91 | } 92 | 93 | /// Creates a sequence of curve points from given points with optional corner rounding 94 | /// 95 | /// Parameters: 96 | /// - points: Array of points where each point can be either: 97 | /// - A simple coordinate pair (x, y) 98 | /// - An array containing a coordinate pair and a radius for rounded corners 99 | /// - u: Scaling factor for coordinates 100 | /// 101 | /// Returns: 102 | /// Array of curve commands (move and line operations) with coordinates scaled by u 103 | /// and y-coordinates inverted. If a point includes a radius, it generates rounded 104 | /// corners using _round-corner function. 105 | /// 106 | /// Example: 107 | /// ```typst 108 | /// let points = ((0,0), ((1,1), 0.5), (2,0)) 109 | /// _make-curve(points, 10) 110 | /// ``` 111 | #let _make-curve(points, u) = { 112 | let extract(pt) = { 113 | if type(pt.at(0)) == array { pt.at(0) } else { pt } 114 | } 115 | 116 | let curve-points = for (i, pt) in points.enumerate() { 117 | if type(pt.at(0)) == array { 118 | let (pt, radius) = pt 119 | let p1 = extract(points.at(i - 1)) 120 | let p2 = extract(points.at(i + 1)) 121 | _round-corner(pt, p1, p2, radius, u) 122 | } else { 123 | let (x, y) = pt 124 | if i == 0 { 125 | (curve.move((x * u, y * -u)),) 126 | } else { 127 | (curve.line((x * u, y * -u)),) 128 | } 129 | } 130 | } 131 | curve-points 132 | } 133 | 134 | #let _draw-polygon(p, u) = { 135 | let radius = p.at("corner-radius", default: 0) 136 | 137 | let vertices = p.vertices 138 | let points = if radius <= 0 { 139 | // Simple polygon case 140 | vertices.map(pt => _cpos(pt, u)) 141 | } else { 142 | // Rounded corner case 143 | let (x0, y0) = vertices.at(0) 144 | vertices.push(vertices.at(0)) 145 | vertices.push(vertices.at(1)) 146 | ( 147 | curve.move((x0 * u, y0 * -u)), 148 | ..for (p1, pt, p2) in vertices.windows(3) { 149 | _round-corner(pt, p1, p2, radius, u) 150 | }, 151 | ) 152 | } 153 | let shape = curve(..points, fill: p.fill, stroke: p.stroke) 154 | place(shape) 155 | } 156 | 157 | /* Renders a visual task with various graphical elements. 158 | 159 | This function takes a task object and a unit length, then renders all components 160 | of the task including backgrounds, grids, lines, markers, labels and foreground elements. 161 | 162 | Parameters: 163 | - task: A task object containing: 164 | - grid: Grid configuration with coordinates 165 | - background-color: Color for the canvas background 166 | - background: Array of background elements (polygons with optional labels) 167 | - show-grid: Boolean controlling grid visibility 168 | - lines: Array of line objects with points and stroke styles, sorted by layer 169 | - markers: Array of marker objects with body content and position 170 | - labels: Array of label objects with body, position and visibility settings 171 | - foreground: Array of foreground elements with body and position 172 | - unit-length: The base unit length for scaling coordinates 173 | 174 | The render order is: 175 | 1. Canvas with background color 176 | 2. Background elements 177 | 3. Grid (if enabled) 178 | 4. Lines (sorted by layer) 179 | 5. Markers 180 | 6. Labels 181 | 7. Foreground elements 182 | 183 | Each element is placed according to its specified position scaled by the unit-length. 184 | */ 185 | #let render(task, unit-length) = { 186 | show: _get-canvas(task.grid.coords, unit-length, fill: task.background-color) 187 | 188 | for bg in task.background { 189 | if bg.kind == "polygon" { 190 | _draw-polygon(bg, unit-length) 191 | } 192 | if bg.label != none and bg.label-pos != none { 193 | _draw(bg.label, bg.label-pos, unit-length) 194 | } 195 | } 196 | 197 | if task.show-grid { 198 | _draw-grid(task.grid, unit-length) 199 | } 200 | 201 | for line in task.lines.sorted(key: l => l.layer) { 202 | let curve-points = _make-curve(line.points, unit-length) 203 | if type(line.stroke) == array { 204 | for line-stroke in line.stroke { 205 | place(curve(..curve-points, stroke: line-stroke)) 206 | } 207 | } else { 208 | place(curve(..curve-points, stroke: line.stroke)) 209 | } 210 | } 211 | 212 | for marker in task.markers { 213 | _draw(block(width: 0pt, height: 0pt, align(center + horizon, marker.body)), marker.pos, unit-length) 214 | } 215 | 216 | for label in task.labels { 217 | let content = _anchored(label.body, label.anchor) 218 | if label.hidden { 219 | content = hide(content) 220 | } 221 | _draw(content, label.pos, unit-length) 222 | } 223 | 224 | for fg in task.foreground { 225 | let body = if "anchor" in fg { 226 | _anchored(fg.body, fg.anchor) 227 | } else { 228 | fg.body 229 | } 230 | _draw(body, fg.pos, unit-length) 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/components/common.typ: -------------------------------------------------------------------------------- 1 | 2 | #let line-stroke(line, sec, thickness: 6pt) = { 3 | let paint = if line.disabled { gray } else { line.color } 4 | stroke( 5 | paint: paint, 6 | thickness: thickness, 7 | cap: "round", 8 | join: "round", 9 | ) 10 | } 11 | 12 | #let label-renderer(station) = { 13 | show: block.with(inset: (x: 0.5em, y: 0.5em)) 14 | 15 | [#station.name] 16 | } 17 | -------------------------------------------------------------------------------- /src/components/mod.typ: -------------------------------------------------------------------------------- 1 | 2 | #let use(backend) = { 3 | if backend == "std" { 4 | import "std.typ" as comp 5 | comp 6 | } else { 7 | panic("unknown backend for components: ", backend) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/std.typ: -------------------------------------------------------------------------------- 1 | #import "common.typ": * 2 | #import "../core/utils.typ": get-preferred-angle as _get-preferred-angle 3 | 4 | 5 | #let primary-color = rgb("#112653") 6 | #let normal-marker = color => circle(fill: white, stroke: color, radius: 2.5pt) 7 | #let terminal-marker = color => circle(fill: white, stroke: color + 1.0pt, radius: 5pt) 8 | #let capsule-marker = rect(width: 14pt, height: 7pt, fill: white, stroke: primary-color + 1pt, radius: 4pt) 9 | #let circle-marker = circle(fill: white, stroke: primary-color + 1pt, radius: 8pt) 10 | 11 | 12 | #let marker-renderer(line, station, tr-lines, tr-stations) = { 13 | if tr-lines == none { 14 | return if "terminal" in station { 15 | terminal-marker(line.color) 16 | } else { 17 | normal-marker(line.color) 18 | } 19 | } 20 | if tr-lines.len() > 2 { 21 | return circle-marker 22 | } 23 | if tr-lines.len() == 2 { 24 | let angle = station.metadata.at("marker-rotation", default: none) 25 | if angle == none { 26 | let angles = for (line2, sta2) in tr-lines.zip(tr-stations) { 27 | (line2.segments.at(sta2.segment).angle,) 28 | } 29 | angle = _get-preferred-angle(angles) 30 | } 31 | show: rotate.with(-angle) 32 | capsule-marker 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/core/anchor.typ: -------------------------------------------------------------------------------- 1 | #import "dir.typ": dirs 2 | #import "utils.typ": min-index 3 | 4 | 5 | #let _anchors = (dirs.W, dirs.SW, dirs.S, dirs.NW, dirs.E, dirs.NE, dirs.N, dirs.NW) 6 | #let _anchor-orders = (0, 2, 1, 3) 7 | 8 | /// Find a best anchor placement with least punishment. 9 | /// Determines the best anchor position based on path segments and geometric analysis. 10 | /// 11 | /// This function analyzes a path context and determines the optimal anchor direction 12 | /// by evaluating angles between segments and applying punishment scores. 13 | /// 14 | /// - tr-ctx (array): A collection of tuples containing (position, segment index, segments array). 15 | /// -> str 16 | #let get-best-anchor-tr(tr-ctx) = { 17 | // Algorithm: 18 | // 1. Initializes punishment scores for 8 directions (0° to 315° in 45° increments) 19 | // 2. For each point in the path: 20 | // - Collects target points from adjacent segments 21 | // - Calculates angles to target points 22 | // - Assigns punishment scores based on angle proximity and orthogonality 23 | // 3. Returns the anchor direction with the lowest punishment score 24 | 25 | let punishment = (0, 1) * 4 // for 0deg, 45deg, ..., 315deg; prefer ortho 26 | for (pos, seg-idx, segments) in tr-ctx { 27 | let seg = segments.at(seg-idx) 28 | 29 | // collect ray targets 30 | let targets = () 31 | if pos != seg.start { 32 | targets.push(seg.start) 33 | } else if seg-idx > 0 { 34 | // consider previous segment 35 | targets.push(segments.at(seg-idx - 1).start) 36 | } 37 | if pos != seg.end { 38 | targets.push(seg.end) 39 | } else if seg-idx + 1 < segments.len() { 40 | // consider next segment 41 | targets.push(segments.at(seg-idx + 1).end) 42 | } 43 | 44 | for target in targets { 45 | let angle = calc.atan2(target.at(0) - pos.at(0), target.at(1) - pos.at(1)) 46 | if angle <= -22.5deg { angle += 360deg } 47 | let di = calc.rem(int((angle + 22.5deg) / 45deg), 8) 48 | let di1 = calc.rem(di + 1, 8) // next 49 | let di2 = calc.rem(di + 7, 8) // prev 50 | punishment.at(di) += 16 51 | if calc.rem(di, 2) == 0 { 52 | // right, left, up, down 53 | punishment.at(di1) += 1 54 | punishment.at(di2) += 1 55 | } else if di == 1 or di == 5 { 56 | punishment.at(di1) += 8 57 | punishment.at(di2) += 4 58 | } else if di == 3 or di == 7 { 59 | punishment.at(di1) += 4 60 | punishment.at(di2) += 8 61 | } 62 | } 63 | } 64 | // find the direction with minimum punishment 65 | return _anchors.at(min-index(punishment)) 66 | } 67 | 68 | /// Returns the best anchor position for a line segment. 69 | /// The function determines the optimal anchor point based on the line segment's angle. 70 | /// 71 | /// angle (angle): The angle of the line segment. 72 | /// -> str 73 | #let get-best-anchor(angle) = { 74 | let angle = angle + 90deg 75 | if angle <= -22.5deg { angle += 180deg } 76 | let q = calc.rem(int((angle + 22.5deg) / 45.0deg), 4) 77 | return _anchors.at(q) 78 | } 79 | -------------------------------------------------------------------------------- /src/core/dir.typ: -------------------------------------------------------------------------------- 1 | 2 | /// Cardinal and intercardinal direction constants. 3 | #let dirs = ( 4 | N: "north", 5 | S: "south", 6 | W: "west", 7 | E: "east", 8 | NW: "north-west", 9 | NE: "north-east", 10 | SW: "south-west", 11 | SE: "south-east", 12 | ) 13 | 14 | /// Resolves the final position of a segment end point based on given parameters. 15 | /// 16 | /// The function handles several cases: 17 | /// 1. When coordinates are 'auto', calculates actual position based on direction; 18 | /// 2. For cardinal directions `(N,S,E,W)`, uses appropriate offset; 19 | /// 3. For diagonal directions `(NE,SE,NW,SW)`, calculates position maintaining 45° angles; 20 | /// 4. When direction is 'auto', uses available offset or maintains last position. 21 | /// 22 | /// Returns the modified `end-pos`. 23 | /// 24 | /// - origin (dictionary): The previous position object with x, y coordinates. 25 | /// - target (dictionary): A position object containing x, y coordinates, dx, dy offsets and direction. 26 | /// -> dictionary 27 | #let resolve-target-pos(origin, target) = { 28 | let dir = target.d 29 | let x = if target.x != auto { 30 | target.x 31 | } else if dir == auto { 32 | if target.dx != auto { 33 | origin.x + target.dx 34 | } else { 35 | origin.x 36 | } 37 | } else if dir == dirs.E or dir == dirs.W { 38 | origin.x + target.dx 39 | } else if dir == dirs.N or dir == dirs.S { 40 | origin.x 41 | } else if dir == dirs.NE or dir == dirs.SW { 42 | let dx = if target.dx != auto { 43 | target.dx 44 | } else if target.dy != auto { 45 | target.dy 46 | } else { 47 | target.y - origin.y 48 | } 49 | origin.x + dx 50 | } else if dir == dirs.SE or dir == dirs.NW { 51 | let dx = if target.dx != auto { 52 | target.dx 53 | } else if target.dy != auto { 54 | -target.dy 55 | } else { 56 | -(target.y - origin.y) 57 | } 58 | origin.x + dx 59 | } else { 60 | origin.x 61 | } 62 | let y = if target.y != auto { 63 | target.y 64 | } else if dir == auto { 65 | if target.dy != auto { 66 | origin.y + target.dy 67 | } else { 68 | origin.y 69 | } 70 | } else if dir == dirs.N or dir == dirs.S { 71 | origin.y + target.dy 72 | } else if dir == dirs.E or dir == dirs.W { 73 | origin.y 74 | } else if dir == dirs.NE or dir == dirs.SW { 75 | let dy = if target.dy != auto { 76 | target.dy 77 | } else if target.dx != auto { 78 | target.dx 79 | } else { 80 | target.x - origin.x 81 | } 82 | origin.y + dy 83 | } else if dir == dirs.SE or dir == dirs.NW { 84 | let dy = if target.dy != auto { 85 | target.dy 86 | } else if target.dx != auto { 87 | -target.dx 88 | } else { 89 | -(target.x - origin.x) 90 | } 91 | origin.y + dy 92 | } else { 93 | origin.y 94 | } 95 | (x, y) 96 | } 97 | 98 | /// Takes a variable number of points with walking directions and returns an array of resolved positions. 99 | /// 100 | /// *Example:* 101 | /// ```example 102 | /// #walk((0, 0), (y: 2), (x: 2, d: "north-east"), (dx: 2)) 103 | /// ``` 104 | /// 105 | /// - ..points (arguments): Variable number of points. Each point should be either: 106 | /// - An array with `(x, y)` coordinates; 107 | /// - A dictionary with optional keys `(x, y, dx, dy, d)`; 108 | /// -> array 109 | #let walk(..points) = { 110 | let points = points.pos() 111 | assert(points.len() > 0, message: "You should provide at least one point!") 112 | 113 | let auto-pos = (x: auto, y: auto, dx: auto, dy: auto, d: auto) 114 | let last-pos = none 115 | let resolved = () 116 | 117 | for p in points { 118 | let cur-pos = auto-pos + (if type(p) == array { (x: p.at(0), y: p.at(1)) } else { p }) 119 | cur-pos = resolve-target-pos(last-pos, cur-pos) 120 | resolved.push(cur-pos) 121 | last-pos = (x: cur-pos.at(0), y: cur-pos.at(1)) 122 | } 123 | 124 | resolved 125 | } 126 | -------------------------------------------------------------------------------- /src/core/feature.typ: -------------------------------------------------------------------------------- 1 | 2 | /// Resolves all dependent features for a given array of enabled features. 3 | /// 4 | /// This function performs a depth-first traversal of feature dependencies 5 | /// to build a complete list of features that should be enabled. 6 | /// 7 | /// *Example:* 8 | /// ```example 9 | /// #let features = ( 10 | /// "a": ("b", "c"), 11 | /// "b": ("d",), 12 | /// ) 13 | /// #let enabled = ("a",) 14 | /// #resolve-enabled-features(features, enabled) 15 | /// // Returns ("a", "b", "c", "d") 16 | /// ``` 17 | /// 18 | /// - features (dictionary): A mapping of feature names to their dependencies. 19 | /// - enabled-features (array): Initial list of explicitly enabled features. 20 | /// 21 | /// -> array 22 | #let resolve-enabled-features(features, enabled-features) = { 23 | let all-enabled-features = enabled-features 24 | let work-list = enabled-features 25 | while work-list.len() > 0 { 26 | let current = work-list.pop() 27 | if current in features { 28 | for dep in features.at(current) { 29 | if not all-enabled-features.contains(dep) { 30 | all-enabled-features.push(dep) 31 | work-list.push(dep) 32 | } 33 | } 34 | } 35 | } 36 | return all-enabled-features 37 | } 38 | -------------------------------------------------------------------------------- /src/core/mod.typ: -------------------------------------------------------------------------------- 1 | /// Public symbols for core functions. 2 | #import "anchor.typ" 3 | #import "dir.typ" 4 | #import dir: dirs 5 | #import "feature.typ" 6 | #import "utils.typ" 7 | #import "vec.typ" 8 | -------------------------------------------------------------------------------- /src/core/utils.typ: -------------------------------------------------------------------------------- 1 | /// Utility functions. 2 | 3 | 4 | #let lerp(a, b, r) = { 5 | a + (b - a) * r 6 | } 7 | 8 | /// Creates an array from the given input. 9 | /// 10 | /// Returns: 11 | /// - If input is none: empty array 12 | /// - If input is array: same array 13 | /// - Otherwise: single-element array containing the input 14 | /// 15 | /// *Example:* 16 | /// ```example 17 | /// #make-array(none) // returns () 18 | /// #make-array((1,2)) // returns (1,2) 19 | /// #make-array(1) // returns (1,) 20 | /// ``` 21 | /// 22 | /// - a (any): Any value or array 23 | /// -> array 24 | #let make-array(a) = { 25 | if a == none { () } else if type(a) == array { a } else { (a,) } 26 | } 27 | 28 | /// Returns the index of the minimum element in an array, or -1 if the sequence is empty. 29 | /// 30 | /// *Example:* 31 | /// ```example 32 | /// #min-index((3, 1, 4, 1, 5)) // Returns 1 33 | /// #min-index(()) // Returns -1 34 | /// ``` 35 | /// 36 | /// - a (array): An array of comparable elements. 37 | /// -> int 38 | #let min-index(a) = { 39 | if a.len() == 0 { 40 | return -1 41 | } 42 | let k = 0 43 | for (i, x) in a.enumerate() { 44 | if x < a.at(k) { 45 | k = i 46 | } 47 | } 48 | return k 49 | } 50 | 51 | /// Returns an array of elements that appear exactly once in the input array. 52 | /// The elements in the result are sorted in ascending order. 53 | /// 54 | /// *Example:* 55 | /// ```example 56 | /// #pick-once-elements((1,2,2,3,3,3,4)) // Returns (1,4) 57 | /// ``` 58 | /// 59 | /// - a (array): An array of comparable elements. 60 | /// -> array 61 | #let pick-once-elements(a) = { 62 | a = a.sorted() 63 | let res = () 64 | let len = a.len() 65 | let i = 0 66 | while i < len { 67 | let j = i 68 | while j < len and a.at(j) == a.at(i) { 69 | j += 1 70 | } 71 | if j == i + 1 { 72 | res.push(a.at(i)) 73 | } 74 | i = j 75 | } 76 | return res 77 | } 78 | 79 | /// Get a suitable rotation of the transfer marker for the given station. 80 | /// Returns the preferred angle for labeling based on given angles. 81 | /// 82 | /// Priority order: 83 | /// 1. For parallel lines: perpendicular to the common angle (angle + 90°) 84 | /// 2. Horizontal (0°) if present among normalized angles 85 | /// 3. Vertical (90°) if present among normalized angles 86 | /// 4. Direction of first line as fallback 87 | /// 88 | /// All input angles are first normalized to range (-90°, 90°\] by adding 89 | /// or subtracting 180° as needed. 90 | /// 91 | /// *Example:* 92 | /// ```example 93 | /// #get-preferred-angle((45deg, 45deg)) // Returns 135deg (perpendicular) 94 | /// #get-preferred-angle((0deg, 45deg)) // Returns 0deg (horizontal preferred) 95 | /// #get-preferred-angle((90deg, 45deg)) // Returns 90deg (vertical when no horizontal) 96 | /// #get-preferred-angle((30deg, 60deg)) // Returns 30deg (fallback to first) 97 | /// ``` 98 | /// 99 | /// - angles (array): Array of angles. 100 | /// -> angle 101 | #let get-preferred-angle(angles) = { 102 | let angles = for angle in angles { 103 | if angle <= -90deg { angle += 180deg } 104 | if angle > 90deg { angle -= 180deg } 105 | (angle,) 106 | } 107 | return if angles.dedup().len() == 1 { 108 | // parallel case 109 | angles.at(0) + 90deg 110 | } else if angles.contains(0deg) { 111 | // prefer horizontal 112 | 0deg 113 | } else if angles.contains(90deg) { 114 | 90deg 115 | } else { 116 | // along the direction of the first line 117 | angles.at(0) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/core/vec.typ: -------------------------------------------------------------------------------- 1 | 2 | /// Add two vectors. 3 | /// 4 | /// - v1 (vec2): 5 | /// - v2 (vec2): 6 | /// -> vec2 7 | #let add(v1, v2) = { 8 | let (x1, y1) = v1 9 | let (x2, y2) = v2 10 | (x1 + x2, y1 + y2) 11 | } 12 | 13 | /// Calculate the average of vectors. 14 | /// 15 | /// - vectors (array): 16 | /// -> vec2 17 | #let average(vectors) = { 18 | let (x, y) = (0, 0) 19 | let cnt = 0 20 | for (x1, y1) in vectors { 21 | x += x1 22 | y += y1 23 | cnt += 1 24 | } 25 | if cnt > 0 { 26 | x /= cnt 27 | y /= cnt 28 | } 29 | return (x, y) 30 | } 31 | 32 | /// (Copied from `cetz.intersection`) 33 | /// 34 | /// Checks for a line-line intersection between the given points and returns its position, otherwise {{none}}. 35 | /// 36 | /// - a (vec2): Line 1 point 1 37 | /// - b (vec2): Line 1 point 2 38 | /// - c (vec2): Line 2 point 1 39 | /// - d (vec2): Line 2 point 2 40 | /// - ray (bool): When `true`, intersections will be found for the whole line instead of inbetween the given points. 41 | /// -> vec2, none 42 | #let intersect-line-line(a, b, c, d, ray: false) = { 43 | let lli8(x1, y1, x2, y2, x3, y3, x4, y4) = { 44 | let nx = (x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4) 45 | let ny = (x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4) 46 | let d = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4) 47 | if d == 0 { 48 | return none 49 | } 50 | return (nx / d, ny / d) 51 | } 52 | let pt = lli8(a.at(0), a.at(1), b.at(0), b.at(1), c.at(0), c.at(1), d.at(0), d.at(1)) 53 | if pt != none { 54 | let on-line(pt, a, b) = { 55 | let (x, y) = pt 56 | let epsilon = 1e-6 57 | let mx = calc.min(a.at(0), b.at(0)) - epsilon 58 | let my = calc.min(a.at(1), b.at(1)) - epsilon 59 | let Mx = calc.max(a.at(0), b.at(0)) + epsilon 60 | let My = calc.max(a.at(1), b.at(1)) + epsilon 61 | return mx <= x and Mx >= x and my <= y and My >= y 62 | } 63 | if ray or (on-line(pt, a, b) and on-line(pt, c, d)) { 64 | return pt 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/deps.typ: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuadnucYard/radishom/c49bd40458bf51d7131c40933cd773d0c74047d2/src/deps.typ -------------------------------------------------------------------------------- /src/elem/line.typ: -------------------------------------------------------------------------------- 1 | #import "../core/dir.typ": resolve-target-pos 2 | #import "../core/utils.typ": lerp 3 | 4 | 5 | /// Creates a pin in the line. 6 | /// 7 | /// As for arguments describing the position, you can just specify one or two. `auto` values will be inferred. 8 | /// 9 | /// - x (auto, float): Absolute X-coordinate of the pin. 10 | /// - y (auto, float): Absolute Y-coordinate of the pin. 11 | /// - dx (auto, float): Relative X-offset from previous pin. 12 | /// - dy (auto, float): Relative Y-offset from previous pin. 13 | /// - d (auto, str): Cardinal/diagonal direction from previous pin. 14 | /// 15 | /// - end (bool): Marks end of a section, allowing disconnected branches. 16 | /// - reverse (auto, bool): Whether the order of subsequent stations should be reversed. 17 | /// - reverse-before (bool): Whether to reverse the order of all previous stations. 18 | /// 19 | /// - cfg (str, none): Enabling conditions for subsequent segments. 20 | /// - cfg-not (str, none): Disabling conditions for subsequent segments. 21 | /// 22 | /// - layer (auto, int): Drawing layer for subsequent segments (higher layers draw on top). 23 | /// - stroke (auto, stroke): Line style for subsequent segments. 24 | /// - corner-radius (float, none): Radius for rounding this corner. 25 | /// 26 | /// - ..metadata (arguments): Additional attributes of subsequent segments as named arguments. 27 | /// 28 | /// -> dictionary 29 | #let pin( 30 | x: auto, 31 | y: auto, 32 | dx: auto, 33 | dy: auto, 34 | d: auto, 35 | end: false, 36 | reverse: auto, 37 | reverse-before: false, 38 | cfg: auto, 39 | cfg-not: auto, 40 | layer: auto, 41 | stroke: auto, 42 | corner-radius: none, 43 | ..metadata, 44 | ) = { 45 | ( 46 | raw-pos: (x: x, y: y, dx: dx, dy: dy, d: d), 47 | end: end, 48 | reversed: reverse, 49 | reverse-before: reverse-before, 50 | cfg: cfg, 51 | cfg-not: cfg-not, 52 | layer: layer, 53 | stroke: stroke, 54 | corner-radius: corner-radius, 55 | metadata: metadata.named(), 56 | ) 57 | } 58 | 59 | /// Close the path by adding a line to the starting point or specified point. 60 | /// 61 | /// - target (auto, str): The target station id where the loop ends. 62 | /// 63 | /// - reverse (auto, bool): Whether the order of subsequent stations should be reversed. 64 | /// - reverse-before (bool): Whether to reverse the order of all previous stations. 65 | /// 66 | /// - cfg (str, none): Enabling conditions for subsequent segments. 67 | /// - cfg-not (str, none): Disabling conditions for subsequent segments. 68 | /// 69 | /// - layer (auto, int): Drawing layer for subsequent segments (higher layers draw on top). 70 | /// - stroke (auto, stroke): Line style for subsequent segments. 71 | /// - corner-radius (float, none): Radius for rounding this corner. 72 | /// 73 | /// - ..metadata (arguments): Additional attributes of subsequent segments as named arguments. 74 | #let loop( 75 | target: auto, 76 | reverse: auto, 77 | reverse-before: false, 78 | cfg: auto, 79 | cfg-not: auto, 80 | layer: auto, 81 | stroke: auto, 82 | corner-radius: none, 83 | ..metadata, 84 | ) = { 85 | ( 86 | loop-target: target, 87 | end: true, 88 | reversed: reverse, 89 | reverse-before: reverse-before, 90 | cfg: cfg, 91 | cfg-not: cfg-not, 92 | layer: layer, 93 | stroke: stroke, 94 | corner-radius: corner-radius, 95 | metadata: metadata.named(), 96 | ) 97 | } 98 | 99 | /// Extracts stations, sections, and segments from a sequence of points defining a metro line. 100 | /// 101 | /// Requires at least two points to define a valid line. 102 | /// Each point in the input array can be either a pin or a station. 103 | /// 104 | /// Returns a dictionary containing stations, sections and segments. 105 | /// 106 | /// - points (array): Array of point objects containing station and pin information 107 | /// - line-id (str): Identifier for the metro line 108 | /// -> dictionary 109 | #let _extract-stations(points, line-id) = { 110 | assert(points.len() >= 2, message: "The metro line must have at least two points!") 111 | 112 | let last-pin = points.at(0) // resolved point 113 | let cur-attrs = ( 114 | reversed: if last-pin.reversed == auto { false } else { last-pin.reversed }, 115 | cfg: if last-pin.cfg == auto { none } else { last-pin.cfg }, 116 | cfg-not: if last-pin.cfg-not == auto { none } else { last-pin.cfg-not }, 117 | layer: if last-pin.layer == auto { 0 } else { last-pin.layer }, 118 | stroke: last-pin.stroke, 119 | metadata: last-pin.metadata, 120 | ) 121 | 122 | let sections = () 123 | let section-points = () 124 | let segments = () 125 | let stations = () 126 | let ordered-stations = () 127 | 128 | let (sx, sy) = (last-pin.raw-pos.x, last-pin.raw-pos.y) 129 | let start-pos = (sx, sy) 130 | section-points.push(start-pos) 131 | let start-station-index = 0 132 | let reverse-first = if cur-attrs.reversed { 0 } else { -1 } 133 | 134 | let seg-first = 1 // Current range of stations in `points`: [`seg-first`, `seg-last`) 135 | while seg-first < points.len() { 136 | let seg-last = seg-first 137 | while "id" in points.at(seg-last) { 138 | seg-last += 1 // skip stations 139 | } 140 | 141 | let cur-pin = points.at(seg-last) 142 | let (tx, ty) = if "loop-target" in cur-pin { 143 | if cur-pin.loop-target != auto { 144 | while start-station-index < stations.len() and stations.at(start-station-index).id != cur-pin.loop-target { 145 | start-station-index += 1 146 | } 147 | stations.at(start-station-index).pos 148 | } else { 149 | start-pos 150 | } 151 | } else { 152 | resolve-target-pos((x: sx, y: sy), cur-pin.raw-pos) 153 | } 154 | 155 | let angle = calc.atan2(tx - sx, ty - sy) 156 | 157 | let seg = ( 158 | start: (sx, sy), 159 | end: (tx, ty), 160 | angle: angle, 161 | range: (start: stations.len(), end: stations.len() + seg-last - seg-first), 162 | cfg: cur-attrs.cfg, 163 | cfg-not: cur-attrs.cfg-not, 164 | ) 165 | 166 | // process stations on this segment 167 | for sta in points.slice(seg-first, seg-last) { 168 | sta.segment = segments.len() 169 | 170 | let (x, y, r, dx, dy) = sta.remove("raw-pos") 171 | if x == auto and dx != auto { 172 | x = sx + dx 173 | } 174 | if y == auto and dy != auto { 175 | y = sy + dy 176 | } 177 | if r != auto { 178 | x = lerp(sx, tx, r) 179 | y = lerp(sy, ty, r) 180 | } else if x == auto and y != auto { 181 | x = (y - sy) / (ty - sy) * (tx - sx) + sx 182 | } else if y == auto and x != auto { 183 | y = (x - sx) / (tx - sx) * (ty - sy) + sy 184 | } 185 | sta.line = line-id 186 | sta.pos = if x == auto or y == auto { auto } else { (x, y) } // mark pos auto, handle it later 187 | stations.push(sta) 188 | ordered-stations.push(sta.id) 189 | } 190 | if "loop-target" in cur-pin { 191 | let i = start-station-index 192 | while i < stations.len() { 193 | stations.at(i).on-loop = true 194 | i += 1 195 | } 196 | } 197 | if cur-pin.end { 198 | stations.last().trunc = true // mark section truncated here 199 | if stations.last().pos == auto { 200 | stations.last().pos = seg.end 201 | } 202 | } 203 | segments.push(seg) 204 | 205 | // update current pin and cfg 206 | let prev-attrs = cur-attrs 207 | if cur-pin.reversed != auto { cur-attrs.reversed = cur-pin.reversed } 208 | if cur-pin.cfg != auto { cur-attrs.cfg = cur-pin.cfg } 209 | if cur-pin.cfg-not != auto { cur-attrs.cfg-not = cur-pin.cfg-not } 210 | if cur-pin.layer != auto { cur-attrs.cfg-not = cur-pin.layer } 211 | if cur-pin.stroke != auto { cur-attrs.stroke = cur-pin.stroke } 212 | cur-attrs.metadata += cur-pin.metadata 213 | 214 | // add section point 215 | if not last-pin.end { 216 | section-points.push(if cur-pin.corner-radius == none { 217 | seg.end 218 | } else { 219 | (seg.end, cur-pin.corner-radius) 220 | }) 221 | } else { 222 | start-pos = (tx, ty) 223 | start-station-index = stations.len() 224 | } 225 | 226 | if last-pin.end or cur-attrs != prev-attrs { 227 | sections.push((points: section-points, ..prev-attrs)) 228 | section-points = (seg.end,) 229 | // handle reversal 230 | if not cur-attrs.reversed { 231 | if prev-attrs.reversed { 232 | ordered-stations = ordered-stations.slice(0, reverse-first) + ordered-stations.slice(reverse-first).rev() 233 | } 234 | reverse-first = ordered-stations.len() 235 | } 236 | // reverse all stations before 237 | if last-pin.reverse-before { 238 | ordered-stations = ordered-stations.rev() 239 | } 240 | } 241 | 242 | last-pin = cur-pin 243 | sx = tx 244 | sy = ty 245 | seg-first = seg-last + 1 246 | } 247 | if cur-attrs.reversed { 248 | ordered-stations = ordered-stations.slice(0, reverse-first) + ordered-stations.slice(reverse-first).rev() 249 | } 250 | if last-pin.reverse-before { 251 | ordered-stations = ordered-stations.rev() 252 | } 253 | if section-points.len() > 0 { 254 | sections.push((points: section-points, ..cur-attrs)) 255 | } 256 | 257 | // Set positions for terminal stations 258 | if stations.len() > 0 { 259 | if stations.first().pos == auto { 260 | stations.first().pos = segments.first().start 261 | } 262 | if stations.last().pos == auto { 263 | stations.last().pos = segments.last().end 264 | } 265 | } 266 | 267 | return (stations: stations, sections: sections, segments: segments, ordered-stations: ordered-stations) 268 | } 269 | 270 | /// Constructor of metro line. 271 | /// 272 | /// Returns a `line` object with some pending properties that should be decided later in a metro system. 273 | /// 274 | /// - id (str): Unique identifier for the line. 275 | /// - color (color): The color of the line. 276 | /// - index (auto, int): Index position of the line. 277 | /// - optional (bool): Whether the line can be disabled by some features. 278 | /// - features (dictionary): Features for the line. 279 | /// - default-features (array): Default features for the line. 280 | /// - stroke (auto, stroke): Custom stroke for the line. 281 | /// - ..points (arguments): Pins and stations of the line in sequential order. 282 | /// -> dictionary 283 | #let line( 284 | id: "1", 285 | color: gray, 286 | index: auto, 287 | optional: false, 288 | features: (:), 289 | default-features: (), 290 | stroke: auto, 291 | ..points, 292 | ) = { 293 | let (stations, sections, segments, ordered-stations) = _extract-stations(points.pos(), id) 294 | let station-indexer = stations.enumerate().map(((i, sta)) => (sta.id, i)).to-dict() 295 | let data = ( 296 | id: id, 297 | color: color, 298 | index: index, 299 | sections: sections, 300 | segments: segments, 301 | stations: stations, 302 | ordered-stations: ordered-stations, 303 | station-indexer: station-indexer, 304 | optional: optional, 305 | features: features, 306 | default-features: default-features, 307 | metadata: points.named(), 308 | ) 309 | if stroke != auto { data.stroke = stroke } 310 | data 311 | } 312 | -------------------------------------------------------------------------------- /src/elem/metro.typ: -------------------------------------------------------------------------------- 1 | #import "../core/vec.typ" 2 | 3 | /// Calculate the optimal position for a transfer station label based on its anchor and neighboring stations. 4 | /// 5 | /// Returns the optimal label position. 6 | /// 7 | /// - anchor (str): The directional anchor for the label. 8 | /// - tr-positions (array): Positions of transferring stations. 9 | /// - hint (array): Initial (x,y) position hint for the label. 10 | #let get-transfer-label-pos(anchor, tr-positions, hint) = { 11 | let (x, y) = hint 12 | for (x1, y1) in tr-positions { 13 | if "west" in anchor { 14 | x = calc.max(x, x1) 15 | } else if "east" in anchor { 16 | x = calc.min(x, x1) 17 | } 18 | if "south" in anchor { 19 | y = calc.max(y, y1) 20 | } else if "south" in anchor { 21 | y = calc.min(y, y1) 22 | } 23 | } 24 | return (x, y) 25 | } 26 | 27 | /// Analyzes metro lines to identify transfer stations between different lines. 28 | /// 29 | /// This function identifies stations that serve as transfer points between multiple lines. 30 | /// 31 | /// Returns a dictionary where: 32 | /// - keys: Station IDs that serve as transfer points; 33 | /// - values: Arrays of line IDs that intersect at the station. 34 | /// Note: Only stations that connect two or more lines are included in the result. 35 | /// 36 | /// - lines (array): An array of line objects. 37 | /// -> dictionary 38 | #let _resolve-transfers(lines) = { 39 | let station-collection = (:) // station-id -> {line-number} 40 | for line in lines { 41 | for station in line.stations { 42 | if station.transfer != none { 43 | if station.id not in station-collection { 44 | station-collection.insert(station.id, ()) 45 | } 46 | station-collection.at(station.id).push(line.id) 47 | } 48 | } 49 | } 50 | station-collection = station-collection.pairs().filter(((k, v)) => v.len() > 1).to-dict() 51 | return station-collection 52 | } 53 | 54 | /// Resolves station positions in a metro map by processing transfer stations and interpolating positions. 55 | /// 56 | /// Process: 57 | /// 1. Resolves transfer station positions by finding intersections between line segments; 58 | /// 2. Interpolates remaining station positions linearly between known positions within segments. 59 | /// 60 | /// - metro (metro): A metro object. 61 | /// -> dictionary 62 | #let _resolve-stations(metro) = { 63 | let i = 0 64 | let lines = for (_, line) in metro.lines { 65 | // set line index 66 | if line.index == auto { 67 | line.index = i 68 | } 69 | i += 1 70 | 71 | for (k, sta) in line.stations.enumerate() { 72 | // resolve station positions by intersection 73 | if sta.pos == auto and sta.transfer != none and sta.id in metro.transfers { 74 | // find transfer station with the same name on another line 75 | let intersection = none 76 | 77 | let seg = line.segments.at(sta.segment) 78 | for line-id in metro.transfers.at(sta.id) { 79 | let line2 = metro.lines.at(line-id) 80 | if line2.id != line.id { 81 | let sta2 = line2.stations.at(line2.station-indexer.at(sta.id)) 82 | let seg2 = line2.segments.at(sta2.segment) 83 | } 84 | let sta2 = line2.stations.at(line2.station-indexer.at(sta.id)) 85 | let seg2 = line2.segments.at(sta2.segment) 86 | let pt = vec.intersect-line-line(seg.start, seg.end, seg2.start, seg2.end) 87 | if pt != none { 88 | intersection = pt 89 | break 90 | } 91 | } 92 | 93 | if intersection != none { 94 | line.stations.at(k).pos = intersection 95 | } 96 | } 97 | } 98 | 99 | // resolve pending positions by interpolation 100 | for seg in line.segments { 101 | let start-idx = seg.range.start 102 | let end-idx = seg.range.end 103 | 104 | let last-known-index = -1 105 | let last-known = seg.start 106 | 107 | for (k, sta) in line.stations.slice(start-idx, end-idx).enumerate() { 108 | if sta.pos != auto { 109 | last-known-index = k 110 | last-known = sta.pos 111 | continue 112 | } 113 | // find next known 114 | let next-known = seg.end 115 | let next-known-index = end-idx - start-idx 116 | for kk in range(k + 1, end-idx - start-idx) { 117 | let kkk = start-idx + kk 118 | if line.stations.at(kkk).pos != auto { 119 | next-known = line.stations.at(kkk).pos 120 | next-known-index = kk 121 | break 122 | } 123 | } 124 | let pos = { 125 | let (x1, y1) = last-known 126 | let (x2, y2) = next-known 127 | let t = (k - last-known-index) / (next-known-index - last-known-index) 128 | (x1 + (x2 - x1) * t, y1 + (y2 - y1) * t) 129 | } 130 | line.stations.at(start-idx + k).pos = pos 131 | } 132 | } 133 | 134 | ((line.id, line),) 135 | } 136 | lines.to-dict() 137 | } 138 | 139 | /// Constructor of metro system. 140 | /// 141 | /// It processes the input lines to resolve stations and interchanges. 142 | /// 143 | /// - lines (array): An array of line objects. 144 | /// - features (dictionary): Available features for the metro system. 145 | /// - default-features (array): Default features of the metro system. 146 | /// -> metro 147 | #let metro(lines, features: (:), default-features: ()) = { 148 | let transfers = _resolve-transfers(lines) 149 | let mtr = ( 150 | lines: lines.map(line => (line.id, line)).to-dict(), 151 | transfers: transfers, 152 | features: features, 153 | default-features: default-features, 154 | ) 155 | mtr.lines = _resolve-stations(mtr) 156 | mtr 157 | } 158 | -------------------------------------------------------------------------------- /src/elem/mod.typ: -------------------------------------------------------------------------------- 1 | #import "line.typ": line, loop, pin 2 | #import "metro.typ": metro 3 | #import "radish.typ": radish 4 | #import "shapes.typ": polygon 5 | #import "station.typ": station 6 | -------------------------------------------------------------------------------- /src/elem/radish.typ: -------------------------------------------------------------------------------- 1 | /// Feature-based metro instantiation. 2 | 3 | #import "../core/anchor.typ": get-best-anchor, get-best-anchor-tr 4 | #import "../core/feature.typ": resolve-enabled-features 5 | #import "../core/utils.typ": pick-once-elements 6 | 7 | 8 | /// Analyzes line data to identify enabled transfer stations. 9 | /// 10 | /// Takes a dictionary of line objects and processes them to find stations where 11 | /// transfers between different lines are possible. A station is considered a 12 | /// transfer point if it appears in multiple enabled lines and is not disabled. 13 | /// 14 | /// Returns a mapping from station IDs to arrays of line IDs where transfers are possible. 15 | /// Only includes stations that connect to multiple lines. 16 | /// 17 | /// - lines (dictionary): Dictionary mapping line IDs to line objects. 18 | /// -> dictionary 19 | #let _resolve-enabled-transfers(lines) = { 20 | let station-collection = (:) // station-id -> {line-number} 21 | for line in lines.values() { 22 | if line.disabled { continue } 23 | for station in line.stations { 24 | if not station.disabled and station.transfer != none { 25 | if station.id not in station-collection { 26 | station-collection.insert(station.id, ()) 27 | } 28 | station-collection.at(station.id).push(line.id) 29 | } 30 | } 31 | } 32 | station-collection = station-collection.pairs().filter(((k, v)) => v.len() > 1).to-dict() 33 | return station-collection 34 | } 35 | 36 | /// Resolves and updates pending station attributes in a metro system. 37 | /// Returns a dictionary of updated line objects. 38 | /// 39 | /// This function processes all stations in the system and determines their optimal 40 | /// anchor points for labels based on line geometry and transfer connections. 41 | /// Anchor Point Determination: 42 | /// - For non-transfer stations: 43 | /// Uses the angle of the line segment at the station 44 | /// - For transfer stations: 45 | /// Considers geometry of all connected lines to find optimal label placement 46 | /// that avoids visual obstruction 47 | /// 48 | /// - metro (metro): The metro system object containing lines and transfer information. 49 | /// - consider-disabled (bool): Whether to include disabled transfers in anchor computation 50 | /// If set to true, disabled lines will involve in the anchor computation. 51 | /// -> dictionary 52 | #let _resolve-pending-station-attrs(metro, consider-disabled: false) = { 53 | let transfers = if consider-disabled { metro.transfers } else { metro.enabled-transfers } 54 | let lines = for (i, line) in metro.lines { 55 | for (k, sta) in line.stations.enumerate() { 56 | // set station anchor 57 | if sta.anchor == auto { 58 | line.stations.at(k).anchor = if sta.transfer == none or sta.id not in transfers { 59 | let seg = line.segments.at(sta.segment) 60 | get-best-anchor(seg.angle) 61 | } else { 62 | let tr-ctx = for line-id in transfers.at(sta.id) { 63 | let line2 = metro.lines.at(line-id) 64 | let sta2 = line2.stations.at(line2.station-indexer.at(sta.id)) 65 | ((pos: sta2.pos, seg-idx: sta2.segment, segments: line2.segments),) 66 | } 67 | get-best-anchor-tr(tr-ctx) 68 | } 69 | } 70 | } 71 | 72 | ((line.id, line),) 73 | } 74 | lines.to-dict() 75 | } 76 | 77 | /// Instantiate a metro system with given features. 78 | /// It will mark lines, sections, segments, and stations disabled or not. 79 | /// 80 | /// - metro (metro): A metro object. 81 | /// - features (array): Array of enabled features. 82 | /// - default-features (bool): Whether to include default features. 83 | /// - all-features (bool): Whether to include all features. (unimplemented) 84 | /// - enable-all (bool): Whether to enable all elements. 85 | /// -> radish 86 | #let radish( 87 | metro, 88 | features: (), 89 | default-features: true, 90 | all-features: false, 91 | enable-all: false, 92 | consider-disabled: false, 93 | ) = { 94 | let global-enabled-features = resolve-enabled-features( 95 | metro.features, 96 | if default-features { features + metro.default-features } else { features }, 97 | ) 98 | 99 | // we should remove unavailable transfer stations here 100 | for (i, line) in metro.lines { 101 | let enabled-features = ( 102 | global-enabled-features 103 | + resolve-enabled-features( 104 | line.features, 105 | if default-features { global-enabled-features + line.default-features } else { global-enabled-features }, 106 | ) 107 | ) 108 | 109 | let line-id = "L:" + line.id 110 | 111 | let line-disabled = not enable-all and line.optional and not enabled-features.contains(line-id) 112 | line.disabled = line-disabled 113 | if not line-disabled and not enabled-features.contains(line-id) { 114 | enabled-features.push(line-id) 115 | } 116 | 117 | for (j, cp) in line.sections.enumerate() { 118 | line.sections.at(j).disabled = ( 119 | not enable-all and (cp.cfg != none and not enabled-features.contains(cp.cfg)) 120 | ) 121 | } 122 | 123 | for (j, seg) in line.segments.enumerate() { 124 | line.segments.at(j).disabled = ( 125 | not enable-all 126 | and ( 127 | seg.cfg != none and not enabled-features.contains(seg.cfg) 128 | or seg.cfg-not != none and enabled-features.contains(seg.cfg-not) 129 | ) 130 | ) 131 | } 132 | 133 | for (j, sta) in line.stations.enumerate() { 134 | line.stations.at(j).disabled = ( 135 | not enable-all 136 | and ( 137 | line.segments.at(sta.segment).disabled 138 | or "cfg" in sta and not enabled-features.contains(sta.cfg) 139 | or "cfg-not" in sta and enabled-features.contains(sta.cfg-not) 140 | ) 141 | ) 142 | } 143 | 144 | // find terminuses 145 | { 146 | let candidate-terminuses = () 147 | let j = 0 148 | while j < line.stations.len() { 149 | while j < line.stations.len() and (not consider-disabled and line.stations.at(j).disabled) { 150 | j += 1 151 | } 152 | if j >= line.stations.len() { break } 153 | let first-enabled = j 154 | let last-enabled = j 155 | while ( 156 | j < line.stations.len() 157 | and ( 158 | consider-disabled or not line.segments.at(line.stations.at(j).segment).disabled 159 | ) 160 | ) { 161 | last-enabled = j 162 | j += 1 163 | if "trunc" in line.stations.at(last-enabled) { 164 | break 165 | } 166 | } 167 | 168 | candidate-terminuses.push(last-enabled) 169 | if first-enabled != last-enabled and "branch" not in line.stations.at(first-enabled) { 170 | candidate-terminuses.push(first-enabled) 171 | } 172 | } 173 | 174 | // set stations counted once as terminuses 175 | for idx in pick-once-elements(candidate-terminuses) { 176 | if "on-loop" not in line.stations.at(idx) { 177 | line.stations.at(idx).terminal = true 178 | } 179 | } 180 | } 181 | 182 | metro.lines.at(i) = line 183 | } 184 | metro.enabled-transfers = _resolve-enabled-transfers(metro.lines) 185 | metro.lines = _resolve-pending-station-attrs(metro, consider-disabled: consider-disabled) 186 | 187 | metro 188 | } 189 | -------------------------------------------------------------------------------- /src/elem/shapes.typ: -------------------------------------------------------------------------------- 1 | 2 | /// Creates a polygon shape. 3 | /// 4 | /// - fill (color, none): The fill color of the polygon. 5 | /// - stroke (stroke, none): The stroke color and properties of the polygon's outline. 6 | /// - corner-radius (float): The radius of rounded corners. 7 | /// - label (content, none): Text label to be associated with the polygon. 8 | /// - label-pos (vec2): Absolute position of the label . 9 | /// - ..vertices (arguments): Variable number of points defining the polygon's vertices 10 | /// -> dictionary 11 | #let polygon( 12 | fill: none, 13 | stroke: none, 14 | corner-radius: 0, 15 | label: none, 16 | label-pos: none, 17 | ..vertices, 18 | ) = { 19 | ( 20 | kind: "polygon", 21 | fill: fill, 22 | stroke: stroke, 23 | corner-radius: corner-radius, 24 | label: label, 25 | label-pos: label-pos, 26 | vertices: vertices.pos(), 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/elem/station.typ: -------------------------------------------------------------------------------- 1 | #import "../core/utils.typ": make-array 2 | 3 | /// Constructor of metro station. 4 | /// 5 | /// Its position can be inferred. 6 | /// 7 | /// Returns a dictionary containing station data and properties. 8 | /// 9 | /// - name (any): Station name or label content. 10 | /// - id (auto, str): Unique identifier (defaults to name if string). 11 | /// 12 | /// - x (auto, float): Absolute X coordinate. 13 | /// - y (auto, float): Absolute Y coordinate. 14 | /// - dx (auto, float): Relative X offset from the start of the line segment. 15 | /// - dy (auto, float): Relative Y offset from the start of the line segment. 16 | /// - r (auto, float): Position ratio along line segment (0.0 to 1.0). 17 | /// 18 | /// - hidden (bool): Whether to hide the station. 19 | /// - transfer (auto, none): Whether this station is an interchange. 20 | /// - branch (bool): Whether this station is the start of a branch, which indicates it is not terminal. 21 | /// 22 | /// - cfg (str, none): Enabling conditions for this station 23 | /// - cfg-not (str, none): Disabling conditions for this station 24 | /// 25 | /// - anchor (auto, vec2): Text anchor point position 26 | /// - marker-pos (auto, vec2): Custom marker position 27 | /// - marker-offset (none, vec2): Fine-tune marker placement 28 | /// - label-pos (auto, vec2): Custom label position 29 | /// - label-offset (none, vec2): Fine-tune label placement 30 | /// 31 | /// - ..metadata (arguments): Additional station properties as named arguments. 32 | /// 33 | /// -> dictionary 34 | #let station( 35 | name, 36 | id: auto, 37 | x: auto, 38 | y: auto, 39 | dx: auto, 40 | dy: auto, 41 | r: auto, 42 | hidden: false, 43 | transfer: auto, 44 | branch: false, 45 | anchor: auto, 46 | marker-pos: auto, 47 | marker-offset: none, 48 | label-pos: auto, 49 | label-offset: none, 50 | cfg: none, 51 | cfg-not: none, 52 | ..metadata, 53 | ) = { 54 | if id == auto { 55 | id = if type(name) == str { name } else { name.text } 56 | } 57 | let data = ( 58 | id: id, 59 | name: name, 60 | raw-pos: (x: x, y: y, dx: dx, dy: dy, r: r), 61 | anchor: anchor, 62 | transfer: transfer, 63 | metadata: metadata, 64 | ) 65 | if hidden != false { data.hidden = hidden } 66 | if branch != false { data.branch = branch } 67 | if marker-pos != auto { data.marker-pos = marker-pos } 68 | if marker-offset != none { data.marker-offset = marker-offset } 69 | if label-pos != auto { data.label-pos = label-pos } 70 | if label-offset != none { data.label-offset = label-offset } 71 | if cfg != none { data.cfg = cfg } 72 | if cfg-not != none { data.cfg-not = cfg-not } 73 | data 74 | } 75 | -------------------------------------------------------------------------------- /src/lib.typ: -------------------------------------------------------------------------------- 1 | #import "elem/mod.typ": * 2 | #import "radishom.typ": radishom 3 | 4 | #import "core/mod.typ" as core 5 | #import core: dirs 6 | 7 | #import "backends/mod.typ" as backends 8 | #import "components/mod.typ" as components 9 | -------------------------------------------------------------------------------- /src/radishom.typ: -------------------------------------------------------------------------------- 1 | #import "core/vec.typ" 2 | #import "elem/metro.typ": get-transfer-label-pos 3 | #import "backends/mod.typ" as backends 4 | #import "components/mod.typ" as components 5 | 6 | 7 | /// Renders a metro map using the specified backend and configuration. 8 | /// 9 | /// *Note:* 10 | /// 11 | /// When using a custom backend, you need to provide `line-stroker`, `marker-renderer`, 12 | /// and `label-renderer` functions. 13 | /// 14 | /// - radish (radish): A radish object. 15 | /// - backend (str, module, dictionary): The rendering backend to use. Can be `"std"` or custom. 16 | /// 17 | /// - unit-length (length): The base unit length for the map. 18 | /// - grid (auto, none, array): Grid configuration. Can be custom coordinates in form of `((x1, y1), (x2, y2))`. 19 | /// - foreground (array): Collection of foreground elements. 20 | /// - background (array): Collection of background elements. 21 | /// - background-color (color): Background color of the map. 22 | /// - line-stroker (auto, function): Function to generate line strokes. 23 | /// Signature: `(line, section) -> stroke | stroke[]`. 24 | /// - marker-renderer (auto, function): Function to render station markers. 25 | /// Signature: `(line, station, tr-lines, tr-stations) -> content`. 26 | /// - label-renderer (auto, function): Function to render station labels. 27 | /// Signature: `(station) -> content`. 28 | /// - line-plugins (array): Collection of line rendering plugins. 29 | /// Signature: `(line-par) -> content | none`. 30 | /// - station-plugins (array): Collection of station rendering plugins. 31 | /// Signature: `(line-par, station) -> content | none`. 32 | /// - draw-disabled (bool): Whether to draw disabled lines and stations. 33 | /// 34 | /// -> content 35 | #let radishom( 36 | radish, 37 | backend: "std", 38 | unit-length: 1cm, 39 | grid: auto, 40 | foreground: (), 41 | background: (), 42 | background-color: white, 43 | line-stroker: auto, 44 | marker-renderer: auto, 45 | label-renderer: auto, 46 | line-plugins: (), 47 | station-plugins: (), 48 | draw-disabled: false, 49 | ) = { 50 | let (backend, components) = if backend == "std" { 51 | (backends.use("std"), dictionary(components.use("std"))) 52 | } else { 53 | assert( 54 | line-stroker != auto and marker-renderer != auto and label-renderer != auto, 55 | "You should provide component renders in the custom backend", 56 | ) 57 | (backend, none) 58 | } 59 | if line-stroker == auto { 60 | line-stroker = components.line-stroke 61 | } 62 | if marker-renderer == auto { 63 | marker-renderer = components.marker-renderer 64 | } 65 | if label-renderer == auto { 66 | label-renderer = components.label-renderer 67 | } 68 | 69 | // render task 70 | let task = ( 71 | lines: (), 72 | markers: (), 73 | labels: (), 74 | background-color: background-color, 75 | foreground: foreground, 76 | background: background, 77 | ) 78 | 79 | let (min-x, min-y, max-x, max-y) = (0, 0, 0, 0) 80 | 81 | for line in radish.lines.values() { 82 | if line.disabled and not draw-disabled { 83 | continue 84 | } 85 | 86 | let line-par = ( 87 | id: line.id, 88 | color: line.color, 89 | index: line.index, 90 | segments: line.segments, 91 | disabled: line.disabled, 92 | metadata: line.metadata, 93 | ) // partial line used as arg 94 | 95 | let line-stroke = if "stroke" in line { 96 | line.stroke 97 | } 98 | for sec in line.sections { 99 | if sec.disabled and not draw-disabled { 100 | continue 101 | } 102 | let sec-par = ( 103 | layer: sec.layer, 104 | stroke: sec.stroke, 105 | disabled: sec.disabled, 106 | metadata: sec.metadata, 107 | ) 108 | let stroke = if sec.stroke != auto { 109 | sec.stroke 110 | } else if line-stroker != none { 111 | line-stroker(line-par, sec-par) 112 | } else { 113 | line-stroke 114 | } 115 | if stroke != none { 116 | task.lines.push((points: sec.points, stroke: stroke, layer: sec.layer)) 117 | } 118 | } 119 | 120 | // draw stations 121 | for (j, sta) in line.stations.enumerate() { 122 | if sta.disabled and not draw-disabled { 123 | continue 124 | } 125 | 126 | let transfers = if draw-disabled { 127 | radish.transfers.at(sta.id, default: none) 128 | } else { 129 | radish.enabled-transfers.at(sta.id, default: none) 130 | } 131 | let has-transfer = transfers != none 132 | let is-not-first-transfer = has-transfer and line.id != transfers.at(0) 133 | 134 | //check marker 135 | let hidden = sta.at("hidden", default: false) or is-not-first-transfer 136 | 137 | let pos = sta.pos 138 | assert(pos != auto and pos.at(0) != auto) 139 | min-x = calc.min(min-x, pos.at(0)) 140 | min-y = calc.min(min-y, pos.at(1)) 141 | max-x = calc.max(max-x, pos.at(0)) 142 | max-y = calc.max(max-y, pos.at(1)) 143 | 144 | // extract transferred lines 145 | let tr-lines = if has-transfer { 146 | for line-id in transfers { 147 | let line = radish.lines.at(line-id) 148 | ((id: line.id, color: line.color, index: line.index, segments: line.segments),) 149 | } 150 | } 151 | let tr-stations = if has-transfer { 152 | for line-id in transfers { 153 | let line = radish.lines.at(line-id) 154 | (line.stations.at(line.station-indexer.at(sta.id)),) 155 | } 156 | } 157 | let tr-positions = if has-transfer { 158 | for sta2 in tr-stations { (sta2.pos,) } 159 | } 160 | 161 | let marker-pos = if "marker-pos" in sta { 162 | sta.marker-pos 163 | } else if has-transfer { 164 | vec.average(tr-positions) 165 | } else { 166 | pos 167 | } 168 | if "marker-offset" in sta { 169 | marker-pos = vec.add(marker-pos, sta.marker-offset) 170 | } 171 | if not hidden and marker-renderer != none { 172 | let marker = marker-renderer(line-par, sta, tr-lines, tr-stations) 173 | task.markers.push((pos: marker-pos, body: marker)) 174 | } 175 | 176 | if not hidden and label-renderer != none { 177 | let label = { 178 | set align(if "west" in sta.anchor { left } else if "east" in sta.anchor { right } else { center }) 179 | label-renderer(sta) 180 | } 181 | let label-pos = if "label-pos" in sta { 182 | sta.label-pos 183 | } else if has-transfer { 184 | get-transfer-label-pos(sta.anchor, tr-positions, marker-pos) 185 | } else { 186 | pos 187 | } 188 | if "label-offset" in sta { 189 | label-pos = vec.add(label-pos, sta.label-offset) 190 | } 191 | task.labels.push((pos: label-pos, body: label, anchor: sta.anchor, hidden: hidden)) 192 | } 193 | 194 | for plugin in station-plugins { 195 | let fg = plugin(line-par, sta) 196 | if fg != none { 197 | task.foreground.push(fg) 198 | } 199 | } 200 | } 201 | 202 | for plugin in line-plugins { 203 | let fg = plugin(line-par) 204 | if fg != none { 205 | task.foreground.push(fg) 206 | } 207 | } 208 | } 209 | 210 | task.show-grid = grid != none 211 | if grid == auto or grid == none { 212 | grid = ((calc.floor(min-x - 0.5), calc.floor(min-y - 0.5)), (calc.ceil(max-x + 0.5), calc.ceil(max-y + 0.5))) 213 | } 214 | task.grid = ( 215 | coords: grid, 216 | stroke: gray.transparentize(50%), 217 | heavy-stroke: gray.transparentize(40%) + 2pt, 218 | ) 219 | 220 | backend.render(task, unit-length) 221 | } 222 | -------------------------------------------------------------------------------- /typst.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "radishom" 3 | version = "0.1.0" 4 | entrypoint = "src/lib.typ" 5 | authors = ["QuadnucYard"] 6 | license = "MIT" 7 | description = "Draw elegant metro maps with ease" 8 | categories = ["visualization"] 9 | keywords = ["metro", "subway", "map"] 10 | repository = "https://github.com/QuadnucYard/radishom" 11 | exclude = ["examples"] 12 | --------------------------------------------------------------------------------