Compare commits

...

62 Commits

Author SHA1 Message Date
Jan Meyer
ac0d7ee068 vault backup: 2026-08-04 13:31:58 2026-08-04 13:31:58 +02:00
Jan Meyer
16e1078562 vault backup: 2026-08-04 13:28:26 2026-08-04 13:28:26 +02:00
Jan Meyer
4cb2c7897e vault backup: 2026-08-04 13:12:55 2026-08-04 13:12:55 +02:00
Jan Meyer
a4a23f2ed3 vault backup: 2026-08-04 13:00:19 2026-08-04 13:00:19 +02:00
Jan Meyer
7432f09be9 chore: add maths6 2026-06-05 16:56:37 +02:00
Jan Meyer
d91ccb84b9 vault backup: 2026-05-31 17:54:10 2026-06-05 16:56:37 +02:00
Jan Meyer
676f7689e0 vault backup: 2026-05-31 17:51:59 2026-06-05 16:56:37 +02:00
Jan Meyer
e94aff9ca5 vault backup: 2026-05-31 17:48:53 2026-06-05 16:56:37 +02:00
Jan Meyer
fd7ddd83ec vault backup: 2026-05-19 13:14:00 2026-05-26 11:30:08 +02:00
Jan Meyer
e1051991a2 vault backup: 2026-04-23 22:39:23 2026-05-26 11:29:48 +02:00
Jan Meyer
a9dc077e73 chore: update OOP 2026-05-18 22:11:28 +02:00
Jan Meyer
35d545c527 vault backup: 2026-04-28 14:27:40 2026-04-28 14:27:40 +02:00
Jan Meyer
4bee68e6c4 vault backup: 2026-04-28 13:50:38 2026-04-28 13:50:38 +02:00
Jan Meyer
02fb38fdda vault backup: 2026-04-28 10:14:29 2026-04-28 10:14:29 +02:00
Jan Meyer
88a52d6bfc vault backup: 2026-04-28 10:06:25 2026-04-28 10:06:26 +02:00
Jan Meyer
0e99eb925b vault backup: 2026-04-28 10:01:01 2026-04-28 10:01:01 +02:00
Jan Meyer
9e152bc038 vault backup: 2026-04-25 21:26:09 2026-04-25 21:26:09 +02:00
Jan Meyer
83c21f6f49 vault backup: 2026-04-25 20:57:51 2026-04-25 20:57:51 +02:00
Jan Meyer
651843dbef vault backup: 2026-04-25 20:57:48 2026-04-25 20:57:48 +02:00
Jan Meyer
9f253ce482 chore: add oop submodule 2026-04-25 20:57:27 +02:00
Jan Meyer
8be8525c02 chore: remove oop lecture on windows 2026-04-25 20:57:01 +02:00
Jan Meyer
707ab68523 Merge remote-tracking branch 'origin/master' 2026-04-25 18:04:42 +02:00
Jan Meyer
8609ff2517 vault backup: 2026-04-25 18:04:39 2026-04-25 18:04:39 +02:00
Jan Meyer
89253c8488 vault backup: 2026-04-21 13:31:23 2026-04-21 13:31:23 +02:00
Jan Meyer
fc216494d4 vault backup: 2026-04-21 13:21:48 2026-04-21 13:29:16 +02:00
Jan Meyer
ba1fe02294 vault backup: 2026-04-21 13:19:44 2026-04-21 13:29:16 +02:00
Jan Meyer
207e858bce vault backup: 2026-04-21 12:51:47 2026-04-21 13:29:16 +02:00
Jan Meyer
00a1be0c96 vault backup: 2026-04-17 15:44:53 2026-04-21 13:29:16 +02:00
Jan Meyer
537d5a1b52 vault backup: 2026-04-17 14:48:01 2026-04-21 13:29:16 +02:00
Jan Meyer
ef3f56ecf6 vault backup: 2026-04-17 14:41:07 2026-04-21 13:29:16 +02:00
Jan Meyer
d393a1d8f3 vault backup: 2026-04-17 14:39:23 2026-04-21 13:29:16 +02:00
Jan Meyer
be522c693a vault backup: 2026-04-17 14:37:30 2026-04-21 13:29:16 +02:00
Jan Meyer
e9ce177940 vault backup: 2026-04-17 14:35:37 2026-04-21 13:29:16 +02:00
Jan Meyer
1646724403 vault backup: 2026-04-17 12:34:18 2026-04-21 13:28:47 +02:00
Jan Meyer
3421d8160d vault backup: 2026-04-19 23:08:40 2026-04-19 23:08:40 +02:00
Jan Meyer
c1b7ba4561 vault backup: 2026-04-19 23:04:59 2026-04-19 23:04:59 +02:00
Jan Meyer
436b9099a6 vault backup: 2026-04-19 23:02:46 2026-04-19 23:02:46 +02:00
Jan Meyer
f7bb320dd2 vault backup: 2026-04-19 22:58:25 2026-04-19 22:58:25 +02:00
Jan Meyer
1a29c78461 vault backup: 2026-04-19 22:50:34 2026-04-19 22:50:34 +02:00
Jan Meyer
ffddcecf64 vault backup: 2026-04-16 12:38:42 2026-04-16 12:38:42 +02:00
Jan Meyer
23819f4d1b vault backup: 2026-04-16 12:34:05 2026-04-16 12:34:05 +02:00
Jan Meyer
0c57ededaa vault backup: 2026-04-16 12:25:30 2026-04-16 12:25:30 +02:00
Jan Meyer
944dc67916 vault backup: 2026-04-16 12:13:50 2026-04-16 12:13:50 +02:00
Jan Meyer
b51f305a53 vault backup: 2026-04-16 11:54:17 2026-04-16 11:54:18 +02:00
Jan Meyer
92379f9480 chore: ignore css snippets and workspace.json 2026-04-16 11:49:48 +02:00
Jan Meyer
ec94b4ae3a vault backup: 2026-04-16 11:48:52 2026-04-16 11:48:52 +02:00
Jan Meyer
de10a87bd5 chore: manual sync 2026-04-16 11:47:56 +02:00
Jan Meyer
477e49c6f2 vault backup: 2026-04-16 09:11:26 2026-04-16 11:47:56 +02:00
Jan Meyer
aa169ffad0 vault backup: 2026-04-16 08:49:27 2026-04-16 11:47:56 +02:00
Jan Meyer
93f3c11b60 vault backup: 2026-04-16 08:46:37 2026-04-16 11:47:56 +02:00
Jan Meyer
78ea2a30c1 vault backup: 2026-04-16 08:40:12 2026-04-16 11:47:56 +02:00
Jan Meyer
0d4f07c5bc vault backup: 2026-04-16 08:33:48 2026-04-16 11:47:56 +02:00
Jan Meyer
9db4f43b39 vault backup: 2026-04-16 08:23:41 2026-04-16 11:47:45 +02:00
Jan Meyer
1194d23d6c vault backup: 2026-04-16 08:03:39 2026-04-16 11:47:45 +02:00
Jan Meyer
ca7771544d vault backup: 2026-04-16 08:01:08 2026-04-16 11:47:34 +02:00
Jan Meyer
650d49c1f1 vault backup: 2026-04-14 13:15:39 2026-04-14 13:15:39 +02:00
Jan Meyer
5c08968f21 vault backup: 2026-04-14 13:13:33 2026-04-14 13:13:33 +02:00
Jan Meyer
212ce636f3 vault backup: 2026-04-14 13:13:33 2026-04-14 13:13:33 +02:00
Jan Meyer
33843cd0e6 vault backup: 2026-04-08 15:10:32 2026-04-08 15:10:32 +02:00
Jan Meyer
8bcf049ced vault backup: 2026-04-08 13:00:12 2026-04-08 13:00:12 +02:00
Jan Meyer
d21c72e4e7 vault backup: 2026-04-08 10:52:50 2026-04-08 10:52:50 +02:00
Jan Meyer
46bb25c44a vault backup: 2026-04-08 10:46:13 2026-04-08 10:46:13 +02:00
252 changed files with 59689 additions and 493 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
.trash/
.obsidian/snippets/
.obsidian/workspace.json

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "40 Extras/OOP/die_einen_da"]
path = 40 Extras/OOP/die_einen_da
url = git@collaborating.tuhh.de:e-24/courses/oop/exo/2026/g08/die_einen_da.git

3
.obsidian/app.json vendored
View File

@@ -8,5 +8,6 @@
"margin": "2",
"downscalePercent": 100
},
"promptDelete": false
"promptDelete": false,
"showUnsupportedFiles": true
}

View File

@@ -1,9 +1,11 @@
{
"theme": "moonstone",
"theme": "obsidian",
"interfaceFontFamily": "Noto Sans",
"cssTheme": "",
"cssTheme": "Minimal",
"enabledCssSnippets": [
"export",
"matugen"
]
],
"accentColor": "#1c8728",
"baseFontSize": 22
}

View File

@@ -1,6 +1,5 @@
[
"obsidian-minimal-settings",
"obsidian-typst-cli",
"edit-in-neovim",
"obsidian-git",
"better-export-pdf",
@@ -10,5 +9,7 @@
"darlal-switcher-plus",
"dataview",
"obsidian-vimrc-support",
"emoji-shortcodes"
"emoji-shortcodes",
"typst-mate",
"inline-math"
]

22
.obsidian/graph.json vendored
View File

@@ -2,21 +2,21 @@
"collapse-filter": true,
"search": "",
"showTags": false,
"showAttachments": false,
"showAttachments": true,
"hideUnresolved": false,
"showOrphans": true,
"showOrphans": false,
"collapse-color-groups": true,
"colorGroups": [],
"collapse-display": true,
"collapse-display": false,
"showArrow": false,
"textFadeMultiplier": 0,
"nodeSizeMultiplier": 1,
"lineSizeMultiplier": 1,
"textFadeMultiplier": -2.1,
"nodeSizeMultiplier": 0.9421875,
"lineSizeMultiplier": 1.8609375,
"collapse-forces": false,
"centerStrength": 0,
"repelStrength": 0,
"linkStrength": 0.500822368421053,
"linkDistance": 30,
"scale": 0.9895212671709476,
"centerStrength": 0.276041666666667,
"repelStrength": 12.0833333333333,
"linkStrength": 0.703125,
"linkDistance": 101,
"scale": 0.5034307057066698,
"close": true
}

View File

@@ -0,0 +1,30 @@
{
"showTitle": true,
"maxLevel": "6",
"displayHeader": true,
"displayFooter": true,
"headerTemplate": "<div style=\"width: 100vw;font-size:10px;text-align:center;\"><span class=\"title\"></span></div>",
"footerTemplate": "<div style=\"width: 100vw;font-size:10px;text-align:center;\"><span class=\"pageNumber\"></span> / <span class=\"totalPages\"></span></div>",
"printBackground": false,
"generateTaggedPDF": false,
"displayMetadata": false,
"debug": false,
"isTimestamp": false,
"enabledCss": false,
"concurrency": "5",
"prevConfig": {
"pageSize": "A4",
"marginType": "3",
"showTitle": true,
"open": true,
"scale": 100,
"landscape": false,
"marginTop": "3.5",
"marginBottom": "2",
"marginLeft": "3.5",
"marginRight": "2",
"displayHeader": false,
"displayFooter": false,
"cssSnippet": "0"
}
}

205
.obsidian/plugins/execute-code/data.json vendored Normal file
View File

@@ -0,0 +1,205 @@
{
"releaseNote2_1_0wasShowed": true,
"persistentOuput": false,
"timeout": 10000,
"allowInput": true,
"wslMode": false,
"shellWSLMode": false,
"onlyCurrentBlock": false,
"nodePath": "node",
"nodeArgs": "",
"jsFileExtension": "js",
"jsInject": "",
"tsPath": "ts-node",
"tsArgs": "",
"tsInject": "",
"latexCompilerPath": "lualatex",
"latexCompilerArgs": "-interaction=nonstopmode",
"latexDoFilter": true,
"latexTexfotPath": "texfot",
"latexTexfotArgs": "--quiet",
"latexDocumentclass": "article",
"latexAdaptFont": "obsidian",
"latexKeepLog": false,
"latexSubprocessesUseShell": false,
"latexMaxFigures": 10,
"latexFigureTitlePattern": "[^\\n][^%`]*\\\\title\\s*\\{(?<name>[^\\}]*)\\}",
"latexDoCrop": false,
"latexCropPath": "pdfcrop",
"latexCropArgs": "--quiet",
"latexCropNoStandalone": true,
"latexCropNoPagenum": true,
"latexSaveSvg": "poppler",
"latexSvgPath": "pdftocairo",
"latexSvgArgs": "-svg",
"latexInkscapePath": "inkscape",
"latexInkscapeArgs": "--pages=all --export-plain-svg",
"latexSavePdf": true,
"latexSavePng": false,
"latexPngPath": "pdftocairo",
"latexPngArgs": "-singlefile -png",
"latexOutputEmbeddings": true,
"latexInvertFigures": true,
"latexCenterFigures": true,
"latexInject": "",
"leanPath": "lean",
"leanArgs": "",
"leanInject": "",
"luaPath": "lua",
"luaArgs": "",
"luaFileExtension": "lua",
"luaInject": "",
"dartPath": "dart",
"dartArgs": "",
"dartFileExtension": "dart",
"dartInject": "",
"csPath": "dotnet-script",
"csArgs": "",
"csFileExtension": "csx",
"csInject": "",
"pythonPath": "python",
"pythonArgs": "",
"pythonEmbedPlots": true,
"pythonFileExtension": "py",
"pythonInject": "",
"shellPath": "bash",
"shellArgs": "",
"shellFileExtension": "sh",
"shellInject": "",
"batchPath": "call",
"batchArgs": "",
"batchFileExtension": "bat",
"batchInject": "",
"groovyPath": "groovy",
"groovyArgs": "",
"groovyFileExtension": "groovy",
"groovyInject": "",
"golangPath": "go",
"golangArgs": "run",
"golangFileExtension": "go",
"goInject": "",
"javaPath": "java",
"javaArgs": "-ea",
"javaFileExtension": "java",
"javaInject": "",
"maxPrologAnswers": 15,
"prologInject": "",
"powershellPath": "powershell",
"powershellArgs": "-file",
"powershellFileExtension": "ps1",
"powershellInject": "$OutputEncoding = [console]::InputEncoding = [console]::OutputEncoding = New-Object System.Text.UTF8Encoding",
"powershellEncoding": "latin1",
"cargoPath": "cargo",
"cargoEvalArgs": "",
"rustInject": "",
"cppRunner": "cling",
"cppFileExtension": "cpp",
"cppInject": "",
"cppArgs": "",
"cppUseMain": false,
"clingPath": "cling",
"clingArgs": "",
"clingStd": "c++17",
"rustFileExtension": "rs",
"RPath": "Rscript",
"RArgs": "",
"REmbedPlots": true,
"RFileExtension": "R",
"rInject": "",
"kotlinPath": "kotlinc",
"kotlinArgs": "-script",
"kotlinFileExtension": "kts",
"kotlinInject": "",
"swiftPath": "swift",
"swiftArgs": "",
"swiftFileExtension": "swift",
"swiftInject": "",
"runghcPath": "runghc",
"ghcPath": "ghc",
"ghciPath": "ghci",
"useGhci": false,
"haskellInject": "",
"mathematicaPath": "wolframscript",
"mathematicaArgs": "-file",
"mathematicaFileExtension": "wls",
"mathematicaInject": "",
"scalaPath": "scala",
"scalaArgs": "",
"scalaFileExtension": "scala",
"scalaInject": "",
"racketPath": "racket",
"racketArgs": "",
"racketFileExtension": "rkt",
"racketInject": "#lang racket",
"fsharpPath": "dotnet",
"fsharpArgs": "fsi",
"fsharpInject": "",
"fsharpFileExtension": "fsx",
"cArgs": "",
"cUseMain": true,
"cInject": "",
"rubyPath": "ruby",
"rubyArgs": "",
"rubyFileExtension": "rb",
"rubyInject": "",
"sqlPath": "psql",
"sqlArgs": "-d <database> -U <user> -f",
"sqlInject": "",
"octavePath": "octave",
"octaveArgs": "-q",
"octaveFileExtension": "m",
"octaveInject": "figure('visible','off') # Necessary to embed plots",
"maximaPath": "maxima",
"maximaArgs": "-qb",
"maximaFileExtension": "mx",
"maximaInject": "",
"applescriptPath": "osascript",
"applescriptArgs": "",
"applescriptFileExtension": "scpt",
"applescriptInject": "",
"zigPath": "zig",
"zigArgs": "run",
"zigInject": "",
"ocamlPath": "ocaml",
"ocamlArgs": "",
"ocamlInject": "",
"phpPath": "php",
"phpArgs": "",
"phpFileExtension": "php",
"phpInject": "",
"jsInteractive": true,
"tsInteractive": false,
"csInteractive": false,
"latexInteractive": false,
"leanInteractive": false,
"luaInteractive": false,
"dartInteractive": false,
"pythonInteractive": true,
"cppInteractive": false,
"prologInteractive": false,
"shellInteractive": false,
"batchInteractive": false,
"bashInteractive": false,
"groovyInteractive": false,
"rInteractive": false,
"goInteractive": false,
"rustInteractive": false,
"javaInteractive": false,
"powershellInteractive": false,
"kotlinInteractive": false,
"swiftInteractive": false,
"mathematicaInteractive": false,
"haskellInteractive": false,
"scalaInteractive": false,
"fsharpInteractive": false,
"cInteractive": false,
"racketInteractive": false,
"rubyInteractive": false,
"sqlInteractive": false,
"octaveInteractive": false,
"maximaInteractive": false,
"applescriptInteractive": false,
"zigInteractive": false,
"ocamlInteractive": false,
"phpInteractive": false
}

14220
.obsidian/plugins/execute-code/main.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,10 @@
{
"id": "execute-code",
"name": "Execute Code",
"version": "2.1.2",
"minAppVersion": "1.7.2",
"description": "Allows you to execute code snippets within a note. Support C, C++, Python, R, JavaScript, TypeScript, LaTeX, SQL, and many more.",
"author": "twibiral",
"authorUrl": "https://www.github.com/twibiral",
"isDesktopOnly": true
}

View File

@@ -0,0 +1,276 @@
/* @settings
name: Execute Code Settings
id: obsidian-execute-code
settings:
-
id: color-section-title
title: Color Settings
type: heading
level: 3
-
id: use-custom-output-color
title: Custom Code Output Color
description: Use a custom color for the output of code blocks
type: class-toggle
default: false
-
id: code-output-text-color
title: Output Text Color
type: variable-color
format: hex
opacity: false
default: '#FFFFFF'
-
id: use-custom-error-color
title: Custom Code Error Color
description: Use a custom color for the error output of code blocks
type: class-toggle
default: false
-
id: code-error-text-color
title: Error Text Color
type: variable-color
format: hex
opacity: false
default: '#FF0000'
*/
button.run-code-button {
display: none;
color: var(--text-muted);
position: absolute;
bottom: 0;
right: 0;
margin: 5px;
padding: 5px 20px 5px 20px;
z-index: 100;
}
button.clear-button {
display: none;
color: var(--text-muted);
position: absolute;
bottom: 0;
left: 0;
margin: 5px;
padding: 5px 20px 5px 20px;
z-index: 100;
}
pre:hover .run-code-button,
pre:hover .clear-button {
display: block;
}
pre:hover .run-button-disabled,
pre:hover .clear-button-disabled {
display: none;
}
.run-button-disabled,
.clear-button-disabled {
display: none;
}
pre:hover code.language-output {
margin-bottom: 28px;
}
:not(.use-custom-output-color) code.language-output span.stdout {
color: var(--text-muted) !important;
}
.use-custom-output-color code.language-output span.stdout {
color: var(--code-output-text-color) !important;
}
:not(.use-custom-error-color) code.language-output span.stderr {
color: red !important;
}
.use-custom-error-color code.language-output span.stderr {
color: var(--code-error-text-color) !important;
}
code.language-output hr {
margin: 0 0 1em;
}
.settings-code-input-box textarea,
.settings-code-input-box input {
min-width: 400px;
min-height: 100px;
font-family: monospace;
resize: vertical;
}
input.interactive-stdin {
font: inherit;
}
.manage-executors-view h3 {
margin: 1em;
}
.manage-executors-view ul {
margin: 1em;
padding: 0;
list-style-type: none;
}
.manage-executors-view ul li {
padding: 0.5em;
background: var(--background-primary-alt);
border-radius: 4px;
display: grid;
flex-direction: column;
margin-bottom: 0.5em;
}
.manage-executors-view small {
text-transform: uppercase;
font-weight: bold;
letter-spacing: 0.1ch;
grid-row: 1;
}
.manage-executors-view .filename {
grid-row: 2;
}
.manage-executors-view li button {
grid-column: 2;
grid-row: 1 / 3;
margin: 0;
padding: 0.25em;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
background: none;
}
.manage-executors-view li button:hover {
background: var(--background-tertiary);
color: var(--icon-color-hover);
}
.manage-executors-view>div {
position: relative;
}
.manage-executors-view .empty-state {
color: var(--text-muted);
padding: 0.5em;
}
.has-run-code-button {
position: relative;
}
.load-state-indicator {
position: absolute;
top: 0.1em;
left: -2em;
width: 2em;
height: 2em;
background: var(--background-primary-alt);
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
color: var(--tx1);
transform: translateX(2em);
transition: transform 0.25s, opacity 0.25s;
opacity: 0;
pointer-events: none;
cursor: pointer;
}
.load-state-indicator svg {
width: 1.5em;
height: 1.5em;
margin: 0.25em;
}
.load-state-indicator.visible {
transform: translateX(0);
opacity: 1;
pointer-events: all;
}
.load-state-indicator::before {
content: "";
box-shadow: -1em 0 1em -0.75em inset var(--background-modifier-box-shadow);
position: absolute;
display: block;
width: 100%;
height: 100%;
transform: translateX(-2em);
opacity: 0;
transition: transform 0.25s, opacity 0.25s;
pointer-events: none;
}
.load-state-indicator.visible::before {
transform: translateX(0);
opacity: 1;
}
/* Hide code blocks with language-output only in markdown view using "markdown-preview-view"*/
.markdown-preview-view pre.language-output {
display: none;
}
.markdown-rendered pre.language-output {
display: none;
}
/* Do not hide code block when exporting to PDF */
@media print {
pre.language-output {
display: block;
}
/* Hide code blocks with language-output only in markdown view using "markdown-preview-view"*/
.markdown-preview-view pre.language-output {
display: block;
}
.markdown-rendered pre.language-output {
display: block;
}
}
/* Center LaTeX vector graphics, confine to text width */
.center-latex-figures img[src*="/figure%20"][src$=".svg"],
.center-latex-figures img[src*="/figure%20"][src*=".svg?"],
.center-latex-figures .stdout img[src*=".svg?"] {
display: block;
margin: auto;
max-width: 100%;
}
/* Invert LaTeX vector graphics in dark mode */
.theme-dark.invert-latex-figures img[src*="/figure%20"][src$=".svg"],
.theme-dark.invert-latex-figures img[src*="/figure%20"][src*=".svg?"],
.theme-dark.invert-latex-figures .stdout img[src*=".svg?"] {
filter: invert(1);
}
/* Allow descriptions in LaTeX settings to be selected and copied. */
.selectable-description-text {
-moz-user-select: text;
-khtml-user-select: text;
-webkit-user-select: text;
-ms-user-select: text;
user-select: text;
}
.insert-figure-icon {
margin-left: 0.5em;
}
/* Try to keep description of cmd arguments in LaTeX settings on the same line. */
code.selectable-description-text {
white-space: nowrap;
}

View File

@@ -0,0 +1,6 @@
{
"disableInTable": false,
"disableOnIME": true,
"disableDecorations": false,
"disableAtomicRanges": false
}

422
.obsidian/plugins/inline-math/main.js vendored Normal file
View File

@@ -0,0 +1,422 @@
/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/main.ts
var main_exports = {};
__export(main_exports, {
default: () => NoMoreFlicker
});
module.exports = __toCommonJS(main_exports);
var import_obsidian3 = require("obsidian");
// src/settings.ts
var import_obsidian = require("obsidian");
var DEFAULT_SETTINGS = {
disableInTable: false,
disableOnIME: true,
disableDecorations: false,
disableAtomicRanges: false
};
var NoMoreFlickerSettingTab = class extends import_obsidian.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
new import_obsidian.Setting(containerEl).setName("Disable in tables").setDesc("If turned on, braces won't be inserted in tables. Decorations & atomic ranges are enabled regardless of this setting.").addToggle((toggle) => {
toggle.setValue(this.plugin.settings.disableInTable).onChange(async (disable) => {
this.plugin.settings.disableInTable = disable;
await this.plugin.saveSettings();
});
});
new import_obsidian.Setting(containerEl).setName("Disable when using IME input").setDesc("This option can be helpful for avoiding some strange behavior occurring when using IME inputs after escaping from a math block with the Latex Suite plugin's tabout feature.").addToggle((toggle) => {
toggle.setValue(this.plugin.settings.disableOnIME).onChange(async (disable) => {
this.plugin.settings.disableOnIME = disable;
await this.plugin.saveSettings();
});
});
new import_obsidian.Setting(containerEl).setName("Debug mode").setHeading();
new import_obsidian.Setting(containerEl).setName("Disable decorations").setDesc("If turned on, decorations to hide braces adjacent to dollar signs are disabled. This is especially useful when you want to see what this plugin does under the hood.").addToggle((toggle) => {
toggle.setValue(this.plugin.settings.disableDecorations).onChange(async (disable) => {
this.plugin.settings.disableDecorations = disable;
this.plugin.remakeViewPlugin();
await this.plugin.saveSettings();
});
});
new import_obsidian.Setting(containerEl).setName("Disable atomic ranges").setDesc(createFragment((el) => {
el.createSpan({ text: 'If turned on, atomic ranges to treat each of "' });
el.createEl("code", { text: "${} " });
el.createSpan({ text: '" or "' });
el.createEl("code", { text: " {}$" });
el.createSpan({ text: '" as one character are disabled.' });
})).addToggle((toggle) => {
toggle.setValue(this.plugin.settings.disableAtomicRanges).onChange(async (disable) => {
this.plugin.settings.disableAtomicRanges = disable;
this.plugin.remakeViewPlugin();
await this.plugin.saveSettings();
});
});
new import_obsidian.Setting(containerEl).addButton((button) => {
button.setButtonText("Restore defaults").onClick(async () => {
this.plugin.settings = Object.assign({}, DEFAULT_SETTINGS);
await this.plugin.saveSettings();
this.display();
});
});
}
};
// src/decoration-and-atomic-range.ts
var import_state = require("@codemirror/state");
var import_view = require("@codemirror/view");
var import_language2 = require("@codemirror/language");
// src/utils.ts
var import_language = require("@codemirror/language");
var INLINE_MATH_BEGIN = "formatting_formatting-math_formatting-math-begin_keyword_math";
var MATH_END = "formatting_formatting-math_formatting-math-end_keyword_math_math-";
function nodeText(node, state) {
return state.sliceDoc(node.from, node.to);
}
function isInlineMathBegin(node, state) {
return node.name == INLINE_MATH_BEGIN && nodeText(node, state) == "$";
}
function isInlineMathEnd(node, state) {
return node.name == MATH_END && nodeText(node, state) == "$";
}
function selectionSatisfies(state, predicate) {
let ret = false;
const tree = (0, import_language.syntaxTree)(state);
for (const { from } of state.selection.ranges) {
const line = state.doc.lineAt(from);
tree.iterate({
from: line.from,
to: line.to,
enter: (node) => {
if (predicate(node)) {
ret = true;
}
}
});
}
return ret;
}
// src/decoration-and-atomic-range.ts
var DummyRangeValue = class extends import_state.RangeValue {
};
var createViewPlugin = (plugin) => import_view.ViewPlugin.fromClass(
class {
constructor(view) {
this.impl(view);
}
update(update) {
this.impl(update.view);
}
impl(view) {
const decorationBulder = new import_state.RangeSetBuilder();
const atomicRangeBulder = new import_state.RangeSetBuilder();
const tree = (0, import_language2.syntaxTree)(view.state);
for (const { from, to } of view.visibleRanges) {
tree.iterate({
from,
to,
enter(node) {
if (isInlineMathBegin(node, view.state)) {
if (view.state.sliceDoc(node.to, node.to + 3) == "{} ") {
decorationBulder.add(
node.to,
node.to + 3,
import_view.Decoration.replace({})
);
atomicRangeBulder.add(
node.from,
node.to + 3,
new DummyRangeValue()
);
}
} else if (isInlineMathEnd(node, view.state)) {
if (view.state.sliceDoc(node.from - 3, node.from) == " {}") {
decorationBulder.add(
node.from - 3,
node.from,
import_view.Decoration.replace({})
);
atomicRangeBulder.add(
node.from - 3,
node.to,
new DummyRangeValue()
);
}
}
}
});
}
this.decorations = decorationBulder.finish();
this.atomicRanges = atomicRangeBulder.finish();
}
},
{
decorations: (instance) => plugin.settings.disableDecorations ? import_view.Decoration.none : instance.decorations,
provide: (viewPlugin) => import_view.EditorView.atomicRanges.of((view) => {
var _a, _b;
return plugin.settings.disableAtomicRanges ? import_state.RangeSet.empty : (_b = (_a = view.plugin(viewPlugin)) == null ? void 0 : _a.atomicRanges) != null ? _b : import_state.RangeSet.empty;
})
}
);
// src/transaction-filter.ts
var import_state3 = require("@codemirror/state");
var import_language4 = require("@codemirror/language");
// src/latex-suite.ts
var import_state2 = require("@codemirror/state");
var import_language3 = require("@codemirror/language");
function handleLatexSuite(tr, plugin) {
if (tr.docChanged && !tr.selection) {
const changes = handleLatexSuiteBoxing(tr.startState, tr.changes);
if (changes) {
plugin._latexSuiteBoxing = true;
return { changes };
}
} else if (!tr.docChanged && tr.selection) {
if (plugin._latexSuiteBoxing) {
plugin._latexSuiteBoxing = false;
return { selection: { anchor: tr.selection.main.anchor - 3 } };
} else {
const selection = handleLatexSuiteTabout(tr.startState, tr.selection);
return [tr, { selection }];
}
}
}
function handleLatexSuiteTabout(state, newSelection) {
const tree = (0, import_language3.syntaxTree)(state);
const doc = state.doc.toString();
const newRanges = [];
for (const range of newSelection.ranges) {
const indexNextDollar = doc.indexOf("$", range.to);
if (indexNextDollar >= 0) {
const node = tree.cursorAt(indexNextDollar, 1).node;
if (range.from === range.to && range.to === indexNextDollar && isInlineMathEnd(node, state) && state.sliceDoc(node.from - 3, node.from) === " {}") {
newRanges.push(import_state2.EditorSelection.cursor(node.to));
continue;
}
}
newRanges.push(range);
}
return import_state2.EditorSelection.create(newRanges, newSelection.mainIndex);
}
function handleLatexSuiteBoxing(state, changes) {
const tree = (0, import_language3.syntaxTree)(state);
let changeToReplace;
changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
if (inserted.toString() === "\\boxed{" + state.sliceDoc(fromA, toA) + "}") {
const nodeFrom = tree.cursorAt(fromA, -1).node;
const nodeTo = tree.cursorAt(toA, 1).node;
if (isInlineMathBegin(nodeFrom, state) && isInlineMathEnd(nodeTo, state)) {
if (state.sliceDoc(fromA, fromA + 3) === "{} " && state.sliceDoc(toA - 3, toA) === " {}") {
changeToReplace = { from: fromA, to: toA, insert: "\\boxed{" + state.sliceDoc(fromA + 3, toA - 3) + "}" };
}
}
}
});
return changeToReplace;
}
// src/transaction-filter.ts
var import_obsidian2 = require("obsidian");
var makeTransactionFilter = (plugin) => {
return import_state3.EditorState.transactionFilter.of((tr) => {
var _a;
if (plugin.shouldIgnore(tr.startState))
return tr;
const userEvent = (_a = tr.annotation(import_state3.Transaction.userEvent)) == null ? void 0 : _a.split(".")[0];
if (userEvent === "input") {
if (plugin.settings.disableOnIME) {
const view = tr.startState.field(import_obsidian2.editorEditorField);
if (view.composing)
return tr;
}
const changes = getChangesForInsertion(tr.startState, tr.changes);
return [tr, { changes }];
} else if (userEvent === "select" && tr.selection) {
const changes = getChangesForSelection(tr.startState, tr.selection);
return [tr, { changes }];
} else if (userEvent === "delete") {
const changes = getChangesForDeletion(tr.startState);
return [tr, { changes }];
} else if (userEvent === void 0) {
const spec = handleLatexSuite(tr, plugin);
if (spec)
return spec;
}
return tr;
});
};
function getChangesForDeletion(state) {
const tree = (0, import_language4.syntaxTree)(state);
const doc = state.doc.toString();
const changes = [];
for (const range of state.selection.ranges) {
const from = range.empty ? range.from - 4 : range.from;
const to = range.to;
const text = state.sliceDoc(from, to);
const index = text.lastIndexOf("$");
if (index == -1) {
continue;
}
const indexNextDollar = doc.indexOf("$", from + index + 1);
const indexPrevDollar = doc.lastIndexOf("$", from);
tree.iterate({
from: indexPrevDollar,
to: indexNextDollar >= 0 ? indexNextDollar : to,
enter(node) {
if (isInlineMathBegin(node, state) && state.sliceDoc(node.to, node.to + 3) == "{} ") {
changes.push({ from: node.to, to: node.to + 3 });
} else if (isInlineMathEnd(node, state) && state.sliceDoc(node.from - 3, node.from) == " {}") {
changes.push({ from: node.from - 3, to: node.from });
}
}
});
}
return changes;
}
function getChangesForInsertion(state, changes) {
const tree = (0, import_language4.syntaxTree)(state);
const doc = state.doc.toString();
const changesToAdd = [];
const beginningOfChanges = /* @__PURE__ */ new Set();
changes.iterChangedRanges((fromA, toA, fromB, toB) => {
beginningOfChanges.add(fromA);
});
for (const range of state.selection.ranges) {
if (range.from >= 1) {
const indexPrevDollar = doc.lastIndexOf("$", range.from - 1);
if (indexPrevDollar >= 0) {
const node = tree.cursorAt(indexPrevDollar, 1).node;
if (isInlineMathBegin(node, state)) {
if (indexPrevDollar === range.from - 1 && beginningOfChanges.has(range.from)) {
changesToAdd.push({ from: indexPrevDollar, to: range.from, insert: "${} " });
continue;
}
if (state.sliceDoc(node.to, node.to + 3) !== "{} ") {
changesToAdd.push({ from: node.to, insert: "{} " });
}
} else if (isInlineMathEnd(node, state) && state.sliceDoc(node.from - 3, node.from) === " {}") {
const openIndex = doc.lastIndexOf("${} ", node.from - 3);
changesToAdd.push({ from: openIndex + 1, to: node.from, insert: doc.slice(openIndex + 4, node.from - 3).trim() });
}
}
}
const indexNextDollar = doc.indexOf("$", range.to);
if (indexNextDollar >= 0) {
const node = tree.cursorAt(indexNextDollar, 1).node;
if (isInlineMathEnd(node, state)) {
if (state.sliceDoc(node.from - 3, node.from) !== " {}") {
changesToAdd.push({ from: node.from, insert: " {}" });
}
} else if (isInlineMathBegin(node, state) && state.sliceDoc(node.to, node.to + 3) === "{} ") {
const closeIndex = doc.indexOf(" {}$", node.to + 3);
if (closeIndex >= 0) {
changesToAdd.push({ from: node.to, to: closeIndex + 3, insert: doc.slice(node.to + 3, closeIndex).trim() });
}
}
}
}
return changesToAdd;
}
function getChangesForSelection(state, newSelection) {
const tree = (0, import_language4.syntaxTree)(state);
const doc = state.doc.toString();
const changes = [];
for (let i = 0; i < newSelection.ranges.length; i++) {
const range = newSelection.ranges[i];
const indexNextDollar = doc.indexOf("$", range.to);
const indexPrevDollar = doc.lastIndexOf("$", range.from - 1);
if (indexPrevDollar >= 0) {
const node = tree.cursorAt(indexPrevDollar, 1).node;
if (isInlineMathEnd(node, state) && state.sliceDoc(node.from - 3, node.from) === " {}") {
const openIndex = doc.lastIndexOf("${} ", node.from - 3);
changes.push({ from: openIndex + 1, to: node.from, insert: doc.slice(openIndex + 4, node.from - 3).trim() });
}
}
if (indexNextDollar >= 0) {
const node = tree.cursorAt(indexNextDollar, 1).node;
if (isInlineMathBegin(node, state) && state.sliceDoc(node.to, node.to + 3) === "{} ") {
const closeIndex = doc.indexOf(" {}$", node.to + 3);
if (closeIndex >= 0) {
changes.push({ from: node.to, to: closeIndex + 3, insert: doc.slice(node.to + 3, closeIndex).trim() });
}
}
}
}
return changes;
}
// src/main.ts
var NoMoreFlicker = class extends import_obsidian3.Plugin {
constructor() {
super(...arguments);
/**
* a view plugin that provides
* - decorations to hide braces adjacent to "$"s
* - & atomic ranges to treat each of "${} " and " {}$" as one character
*/
this.viewPlugin = [];
/**
* Indicates whether the previous transaction was the first of the two transactions
* (1. text replacement & 2. cursor position change) that Latex Suite's "box current equation"
* command produces or not. See the commend in the makeTransactionFilter() method for details.
*/
this._latexSuiteBoxing = false;
}
async onload() {
await this.loadSettings();
await this.saveSettings();
this.addSettingTab(new NoMoreFlickerSettingTab(this.app, this));
this.registerEditorExtension(this.viewPlugin);
this.remakeViewPlugin();
this.registerEditorExtension(makeTransactionFilter(this));
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
shouldIgnore(state) {
return this.settings.disableInTable && selectionSatisfies(
state,
(node) => node.name.includes("HyperMD-table") || node.name.includes("hmd-table")
);
}
remakeViewPlugin() {
this.viewPlugin.length = 0;
this.viewPlugin.push(createViewPlugin(this));
this.app.workspace.updateOptions();
}
};
/* nosourcemap */

View File

@@ -0,0 +1,15 @@
{
"id": "inline-math",
"name": "No more flickering inline math",
"version": "0.3.6",
"minAppVersion": "1.3.0",
"description": "Remove flickering inline math.",
"author": "Ryota Ushio",
"authorUrl": "https://github.com/RyotaUshio",
"fundingUrl": {
"GitHub Sponsor": "https://github.com/sponsors/RyotaUshio",
"Buy Me a Coffee": "https://www.buymeacoffee.com/ryotaushio",
"Ko-fi": "https://ko-fi.com/ryotaushio"
},
"isDesktopOnly": false
}

View File

@@ -8,7 +8,7 @@
"lineWidth": 40,
"lineWidthWide": 50,
"maxWidth": 88,
"textNormal": 16,
"textNormal": 22,
"textSmall": 13,
"imgGrid": false,
"imgWidth": "img-default-width",

View File

@@ -17,6 +17,10 @@
{
"folder": "/",
"template": "99 Templates/New File Template.md"
},
{
"folder": "11 Homework/Mathe II",
"template": "99 Templates/Math Homework Template.md"
}
],
"enable_file_templates": false,

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

217
.obsidian/plugins/typst-mate/data.json vendored Normal file
View File

@@ -0,0 +1,217 @@
{
"concealMathSymbols": true,
"enableConcealMathSymbolRevealDelay": true,
"mathSymbolRevealDelay": 1000,
"complementSymbolWithUnicode": false,
"disableBracketHighlight": false,
"useObsidianTheme": true,
"enableInlinePreview": true,
"revertTabToDefault": false,
"jumpOutsideBracket": true,
"preferInlineExitForSingleLineDisplayMath": true,
"moveToEndOfMathBlockBeforeExiting": false,
"disableMacro": false,
"enableBackgroundRendering": true,
"patchPDFExport": false,
"autoBaseColor": true,
"baseColor": "#000000",
"offset": 0,
"fitToNoteWidthProfile": "Live",
"fitToNoteWidthProfiles": [
{
"name": "A3",
"width": "700pt"
},
{
"name": "A4",
"width": "500pt"
},
{
"name": "A5",
"width": "350pt"
},
{
"name": "Legal",
"width": "516pt"
},
{
"name": "Letter",
"width": "516pt"
},
{
"name": "Tabloid",
"width": "690pt"
}
],
"skipPreparationWaiting": false,
"disablePackageCache": false,
"preamble": "#set page(margin: 0pt, width: auto, height: auto)\n#show raw: set text(size: 1.25em)\n#set text(size: fontsize)\n#import \"@preview/mannot:0.3.1\": *\n#import \"@preview/quick-maths:0.2.1\": shorthands\n#show: shorthands.with(\n ($+-$, sym.plus.minus),\n ($|-$, math.tack),\n)",
"openTypstToolsOnStartup": true,
"enableMathjaxFallback": false,
"applyProcessorToMathJax": false,
"importPath": ".typst",
"enableDebugger": false,
"processor": {
"inline": {
"processors": [
{
"id": "ce",
"renderingEngine": "typst-svg",
"format": "#import \"@preview/typsium:0.3.1\": ce\n#show math.equation: set text(font: (\"New Computer Modern Math\", \"Noto Serif CJK SC\"))\n#ce[{CODE}]",
"styling": "inline",
"noPreamble": false,
"fitToNoteWidth": false,
"syntaxMode": 0
},
{
"id": "tex",
"renderingEngine": "mathjax",
"format": "",
"styling": "inline",
"noPreamble": false,
"fitToNoteWidth": false
},
{
"id": "display",
"renderingEngine": "typst-svg",
"format": "#set page(margin: (x: 0pt, y: 0.3125em))\n#math.equation($ {CODE} $, block: false)",
"styling": "inline",
"noPreamble": false,
"fitToNoteWidth": false,
"syntaxMode": 1,
"useReplaceAll": false
},
{
"id": "",
"renderingEngine": "typst-svg",
"format": "#set page(margin: (x: 0pt, y: 0.3125em))\n${CODE}$",
"styling": "inline",
"noPreamble": false,
"fitToNoteWidth": false,
"syntaxMode": 1,
"useReplaceAll": false
}
]
},
"display": {
"processors": [
{
"id": "block",
"renderingEngine": "typst-svg",
"format": "$ {CODE} $",
"styling": "block",
"noPreamble": false,
"fitToNoteWidth": false,
"syntaxMode": 1,
"useReplaceAll": false
},
{
"id": "",
"renderingEngine": "typst-svg",
"format": "$ {CODE} $",
"styling": "block-center",
"noPreamble": false,
"fitToNoteWidth": false,
"syntaxMode": 1,
"useReplaceAll": false
}
]
},
"codeblock": {
"processors": [
{
"id": "typst",
"renderingEngine": "typst-svg",
"format": "{CODE}",
"styling": "block-center",
"noPreamble": false,
"fitToNoteWidth": true,
"syntaxMode": 0,
"useReplaceAll": false
},
{
"id": "fletcher",
"renderingEngine": "typst-svg",
"format": "#import \"@preview/fletcher:0.5.8\" as fletcher: diagram, node, edge\n{CODE}",
"styling": "block-center",
"noPreamble": false,
"fitToNoteWidth": false,
"syntaxMode": 0,
"useReplaceAll": false
},
{
"id": "lovelace",
"renderingEngine": "typst-svg",
"format": "#import \"@preview/lovelace:0.3.0\": *\n#pseudocode-list[\n{CODE}\n]",
"styling": "block",
"noPreamble": false,
"fitToNoteWidth": false,
"syntaxMode": 0,
"useReplaceAll": false
},
{
"id": "lilaq",
"renderingEngine": "typst-svg",
"format": "#import \"@preview/lilaq:0.5.0\" as lq\n{CODE}",
"styling": "block-center",
"noPreamble": false,
"fitToNoteWidth": false,
"syntaxMode": 0,
"useReplaceAll": false
}
]
},
"excalidraw": {
"processors": [
{
"id": "default",
"renderingEngine": "typst-svg",
"format": "#set page(margin: 0.25em)\n${CODE}$",
"styling": "default",
"noPreamble": false,
"syntaxMode": 1,
"useReplaceAll": false
}
]
}
},
"snippets": [
{
"category": "Matrix",
"name": "mat",
"description": "e.g. mat(3,3)@",
"kind": "display",
"id": "",
"content": "const parts = input.split(\",\").map(s => s.trim());\n\nconst [x, y] = parts.map(Number)\n\nconst rowText = `${(\"#CURSOR, \".repeat(x)).slice(0, -2)} ;\\n`;\nconst contentText = ` ${rowText}`.repeat(y);\n\nreturn `mat(\\n${contentText})`;",
"script": true
},
{
"category": "Matrix",
"name": "matInline",
"description": "e.g. mat(3,3)@",
"kind": "inline",
"id": "",
"content": "const parts = input.split(\",\").map(s => s.trim());\n\nconst [x, y] = parts.map(Number)\n\nconst rowText = `${(\"#CURSOR, \".repeat(x)).slice(0, -2)} ;`;\nconst contentText = `${rowText}`.repeat(y);\n\nreturn `mat(${contentText})`;",
"script": true
},
{
"category": "Cases",
"name": "cases",
"description": "",
"kind": "display",
"id": "",
"content": "cases(#CURSOR \"if\" #CURSOR, #CURSOR \"else\")",
"script": false
},
{
"category": "Cases",
"name": "casesn",
"description": "e.g. casesn(3)@",
"kind": "display",
"id": "",
"content": "const n = Number(input);\nreturn `cases(\\n${(` #CURSOR \"if\" #CURSOR,\\n`).repeat(n-1)} #CURSOR \"else\"\\n)`",
"script": true
}
],
"crashCount": 0
}

27
.obsidian/plugins/typst-mate/main.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,12 @@
{
"id": "typst-mate",
"name": "Typst Mate",
"version": "2.3.2",
"minAppVersion": "1.0.0",
"description": "Render math expressions with Typst instead of MathJax.",
"author": "azyarashi",
"isDesktopOnly": false,
"fundingUrl": {
"Buy me a Coffee": "https://www.buymeacoffee.com/azyarashi"
}
}

View File

@@ -0,0 +1 @@
MIT OR Apache-2.0

View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2024 PgBiel
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 PgBiel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,114 @@
# elembic
Framework for custom elements and types in Typst. **Supports Typst 0.11.0 or later.**
**Read the book:** https://pgbiel.github.io/elembic
**Mirrors:** [GitHub (pgbiel/elembic)](https://github.com/PgBiel/elembic); [Codeberg (pgbiel/elembic)](https://codeberg.org/PgBiel/elembic)
## About
Elembic lets you create [**custom elements,**](https://pgbiel.github.io/elembic/elements/creating/index.html) which are **reusable and customizable document components,** with support for **typechecked fields, [show and set rules](https://pgbiel.github.io/elembic/elements/styling/index.html)** (without using any `state` by default!), **[reference and outline support,](https://pgbiel.github.io/elembic/elements/creating/labels-refs.html)** as well as features not present in Typst elements today such as **[revokable rules](https://pgbiel.github.io/elembic/elements/styling/revoke.html)** and **[nested element selectors](https://pgbiel.github.io/elembic/elements/filters/within.html)**. In addition, Elembic lets you create [**custom types,**](https://pgbiel.github.io/elembic/types/custom-types/index.html) which also support **typechecked fields** but also [**custom casting**](https://pgbiel.github.io/elembic/types/custom-types/casts.html), and can be used in element fields or by themselves in arbitrary Typst code.
Elembic's name comes from "element" + ["alembic"](https://en.wikipedia.org/wiki/Alembic), to indicate that one of Elembic's goals is to experiment with different approaches for this functionality, and to help shape a future update to Typst that includes native custom elements, which will eventually remove the need for using this package.
Its documentation is at the Elembic Handbook: [https://pgbiel.github.io/elembic](https://pgbiel.github.io/elembic)
This library has a few limitations which are [appropriately noted in the book.](https://pgbiel.github.io/elembic/about/limitations.html)
## Who is elembic for?
Elembic is especially suited for:
1. **Packages:** elembic's elements allows creating reusable components which can be freely customized by your package's end users. With typechecking and other features, elembic's got you covered in terms of flexibility. See the ["Simple Theorems" example](https://pgbiel.github.io/elembic/examples/simple-theorems.html) for a sample.
2. **Templates:** elembic's elements can be used for fine-grained configuration of parts of your template. See the ["Simple Thesis" example](https://pgbiel.github.io/elembic/examples/simple-thesis.html) for a sample.
## Installation
Just import the latest elembic version from the package manager and you're ready to go!
```typ
#import "@preview/elembic:1.1.1" as e
// See the full example below
#show: e.set_(element, stroke: red)
// ...
```
## Example
### Custom element
```typ
#import "@preview/elembic:1.1.1" as e: field, types
#let bigbox = e.element.declare(
"bigbox",
prefix: "@preview/my-package,v1",
doc: "A fancy and customizable box.",
display: it => block(fill: it.fill, stroke: it.stroke, inset: 5pt, it.body),
fields: (
field("body", types.option(content), doc: "Box contents", required: true),
field("fill", types.option(types.paint), doc: "Box fill"),
field("stroke", types.option(stroke), doc: "Box border", default: red)
)
)
#bigbox[abc]
#show: e.set_(bigbox, fill: red)
#bigbox(stroke: blue + 2pt)[def]
```
!['abc' with no fill and a thin red stroke, followed by 'def' with red fill and blue thicker stroke](https://github.com/user-attachments/assets/c852cfcd-c0de-446a-999b-5ecaa44809b7)
### Custom type
```typ
#import "@preview/elembic:1.1.1" as e: field, types
#let person = e.types.declare(
"person",
prefix: "@preview/my-package,v1",
doc: "Relevant data for a person.",
fields: (
field("name", str, doc: "Person's name", required: true, named: true),
field("age", int, doc: "Person's age", default: 40),
field("preference", types.any, doc: "Anything the person likes", default: none)
),
casts: (
(from: dictionary),
(from: str, with: person => name => person(name: name)),
)
)
#assert.eq(
e.repr(person(name: "John", age: 50, preference: "soup")),
"person(age: 50, name: \"John\", preference: \"soup\")"
)
// Manually invoke typechecking and cast
#assert.eq(
types.cast((name: "abc", age: 99), person),
(true, person(name: "abc", age: 99))
)
#assert.eq(
types.cast("abc", person),
(true, person(name: "abc"))
)
// Can then use 'person' as the type of an element's field, for example
```
## Source structure
- `pub/`: Contains public re-exports of each module (so we can keep some things private)
- `data`: Functions and constants related to extracting data from elements and types
- `element`: Functions related to creating and using elements and their rules (set rules, revoke rules and so on)
- `filters`: Functions to create and match element filters
- `types`: Functions and constants related to Elembic's custom type system
- `fields`: Functions related to element and type field parsing
## License
Licensed under MIT or Apache-2.0, at your option.

View File

@@ -0,0 +1,584 @@
// Functions to extract data from custom elements and types, as well as associated constants.
// Type constants:
// Used by typeinfos
#let type-key = "__elembic_type"
// To be used by any custom type instances
#let custom-type-key = "__elembic_custom_type"
// Used by custom types themselves
#let custom-type-data-key = "__elembic_custom_type_data"
// Versions:
#let element-version = 5 // v1 = alphas 1 and 2, v2 = alpha 3-v1.0.0-rc2, v3 = v1.0.0, v4 = v1.1.0, v5 = v1.1.1+
#let type-version = 4 // v1 = alphas to v1.0.0-rc2, v2 = v1.0.0, v3 = v1.1.0, v4 = v1.1.1+
#let custom-type-version = 5 // v1 = alphas 1 and 2, v2 = alpha 3-v1.0.0-rc2, v3 = v1.0.0, v4 = v1.1.0, v5 = v1.1.1+
#let current-field-version = 4 // v1 = alphas 1 and 2, v2 = alpha 3-v1.0.0-rc2, v3 = v1.0.0-v1.1.0, v4 = v1.1.1+
// Potential modes for configuration of styles.
// This defines how we declare a set rule (or similar)
// within a certain scope.
#let style-modes = (
// Normal mode: we store metadata in a bibliography.title set rule.
//
// Before doing so, we retrieve the original value for bibliography.title,
// allowing us to restore it later. The effect is that the library is
// fully hygienic, that is, the change to bibliography.title is not perceptible.
//
// The downside is that retrieving the original value for bibliography.title costs
// an additional nested context { } call, of which there is a limit of 64. This means
// that, in this mode, you can have up to 32 non-consecutive set rules.
normal: 0,
// leaky mode: similar to normal mode, but we don't try to preserve the value of bibliography.title
// after applying our changes to the document. This doubles the limit to up to 64 non-consecutive
// set rules since we no longer have an extra step to retrieve the old value, but, as a downside,
// we lose the original value of bibliography.title. While, in a future change, we might be able to
// preserve the FIRST known value, we can't generally preserve its value at later points, so the
// value of bibliography.title is effectively frozen before the first custom set rule.
//
// This mode should be used by package authors which know there won't be a bibliography (or, really,
// any custom user input) at some point to avoid consuming the set rule cost. End users can also use
// this mode if they hit a "max show rule depth exceeded" error.
//
// Note that this mode can only be enabled on individual set rules.
leaky: 1,
// Stateful mode: this is entirely different from the other modes and should only be set by the end
// user (not by packages). This stores the style chain - and, thus, set rules' updated fields - in
// a 'state()'. This is more likely to be slower and lead to trouble as it triggers at least one
// document relayout. However, **this mode does not have a set rule limit.** Therefore, it can be
// used as a last resort by the end user if they can't fix the "max show rule depth exceeded error".
//
// Enabling this mode is as simple as using `#show: e.stateful.toggle(true)` at the beginning of the
// document. This will trigger a compatibility behavior where existing set rules will push to the
// state, even if they're not in the stateful mode. It will also push existing set rule data into
// the style 'state()'. Therefore, existing set rules are compatible with stateful mode, but this
// only effectively fixes the error if the set rules are individually switched to stateful mode
// with `e.stateful.set_` instead of `e.set_`.
stateful: 2
)
// When on stateful mode, this state holds the sequence of 'data' for each scope.
// The last element on the list is the "current" data.
#let style-state-key = "__elembic_element_state"
#let style-state = state(style-state-key, ())
// Element constants:
// Prefix for the labels added to shown elements.
#let lbl-show-head = "__elembic_element_shown_"
// Prefix for the labels added to the metadata of each element.
// Used for querying.
#let lbl-meta-head = "__elembic_element_meta_"
// Prefix for the labels added outside shown elements.
// This is used to be able to effectively apply show-set rules to them.
#let lbl-outer-head = "__elembic_element_outer_"
// Prefix for counters of elements.
// This is only used if the element isn't 'refable'.
#let lbl-counter-head = "__elembic_element_counter_"
// Prefix for labels for 'context' which should panic with 'missing prepare(elem)'.
#let lbl-elem-prepare-check-head = "__elembic_elem_prepare_check_"
// Kind of the special figure used by a labelable element.
#let labelable-elem-figure-kind = "__elembic_element_labelable_figure"
// Prefix for the figure kind used by 'refable' elements.
// This is not to be confused with figures containing the elements.
// This is the kind for a hidden figure used for ref purposes.
#let lbl-ref-figure-kind-head = "__elembic_element_refable_"
// Custom label applied to the hidden reference figure when the user specifies their own label.
#let lbl-ref-figure-label-head = "__elembic_element_ref_figure_label_"
// Label for a context which should panic with 'missing prepare()'...
#let lbl-empty-prepare-check = <__elembic_empty_prepare_check>
// Label for the hidden figure used for references.
#let lbl-ref-figure = <__elembic_element_ref_figure>
// Label for context blocks which have access to the virtual stylechain.
#let lbl-get = <__elembic_element_get>
// Label for metadata indicating an element's initial properties post-construction.
#let lbl-tag = <__elembic_element_tag>
// Label for metadata indicating a rule's parameters.
#let lbl-rule-tag = <__elembic_element_rule_v2>
// 'lbl-rule-tag' from older Elembic versions.
#let lbl-old-rule-tag = <__elembic_element_rule>
// Label for other functions which access or modify the style chain, namely
// 'get', 'select', 'debug-get', 'stateful.toggle'.
//
// This is attached to metadata and the 'special-rule-key' property
// indicates which kind of rule this is.
#let lbl-special-rule-tag = <__elembic_element_special_rule>
// Label for metadata which stores the global data.
// In practice, this label is never present in the document
// unless one accidentally leaks the 'bibliography.title'
// override from our workaround.
#let lbl-data-metadata = <__elembic_element_global_data_metadata>
#let lbl-stateful-mode = <__elembic_element_stateful_mode>
#let lbl-normal-mode = <__elembic_element_normal_mode>
#let lbl-leaky-mode = <__elembic_element_leaky_mode>
#let lbl-auto-mode = <__elembic_element_auto_mode>
// Prefix for labels added by 'select' to matched elements.
// These labels are not specific to eids.
#let lbl-global-select-head = "__elembic_element_global_select_"
// Special dictionary key to indicate this is a prepared rule.
#let prepared-rule-key = "__elembic-prepared-rule"
// Special dictionary key to indicate this is a "special rule"
// ('get', 'select', 'debug-get', 'stateful.toggle').
#let special-rule-key = "__elembic-special-rule"
// Special dictionary key which stores element context and other data.
#let stored-data-key = "__elembic_stored_element_data"
// Special dictionary key to indicate this is query metadata for an element.
#let element-meta-key = "__elembic_element_meta"
#let element-key = "__elembic_element"
#let element-data-key = "__elembic_element_data"
#let global-data-key = "__elembic_element_global_data"
#let filter-key = "__elembic_element_filter"
#let sequence = [].func()
#let styled = { set text(red); [a] }.func()
#let elem-funcs = (sequence, styled, figure)
// Special values that can be passed to a type or element's constructor to retrieve some data or show
// some behavior.
#let special-data-values = (
// Indicate that the constructor should return the type or element's data.
get-data: 0,
// Indicate that the constructor should return an element filter.
get-where: 1,
)
// Extract data from a type's or element's constructor, as well as convert
// a custom type or element instance into a dictionary with keys such as body (for elements only),
// fields and func, allowing you to access its fields and information when given content (for elements)
// or the type instance (for types).
//
// When given content that is not a custom element, 'body' will be the given value,
// 'fields' will be 'body.fields()' and 'func' will be 'body.func()'.
//
// The returned 'data-kind' indicates which kind of data was retrieved.
#let data(it) = {
if type(it) == function {
it(__elembic_data: special-data-values.get-data)
} else if type(it) == dictionary {
if element-key in it {
(data-kind: "element", ..it)
} else if custom-type-data-key in it {
(data-kind: "custom-type-data", ..it)
} else if custom-type-key in it {
it.at(custom-type-key)
} else if stored-data-key in it {
it.at(stored-data-key)
} else {
(data-kind: "unknown", body: it, fields: (:), func: none, eid: none, fields-known: false, valid: false)
}
} else if type(it) != content {
(data-kind: "unknown", body: it, fields: (:), func: none, eid: none, fields-known: false, valid: false)
} else if (
it.func() == styled
and it.has("label")
and it.child.func() == sequence
and it.child.children.len() >= 2
and it.child.children.last().at("label", default: none) == lbl-tag
) {
// Any labeled elements need to be wrapped in a 'styled' to avoid
// interference when joining with the element in a show rule on the
// label.
it.child.children.last().value
} else if it.func() == sequence and it.children.len() >= 2 {
let last = it.children.last()
if (
last.at("label", default: none) == lbl-tag
// Workaround for 0.11.0 weirdly placing some 'meta' element sometimes
or sys.version < version(0, 12, 0) and {
last = it.children.at(it.children.len() - 2)
last.at("label", default: none) == lbl-tag
}
// Pre-1.0 elembic versions didn't add a label to metadata
or str(it.at("label", default: "")).starts-with(lbl-show-head) and last.func() == metadata
) {
// Decomposing a recently-constructed (but not placed) element
last.value
} else {
(data-kind: "content", body: it, fields: it.fields(), func: it.func(), eid: none, fields-known: false, valid: false)
}
} else if it.func() == figure and it.has("kind") and it.kind == labelable-elem-figure-kind {
let last
if it.body.func() == sequence and it.body.children.len() >= 2 and {
last = it.body.children.last()
(
last.at("label", default: none) == lbl-tag
// Workaround for 0.11.0 weirdly placing some 'meta' element sometimes
or sys.version < version(0, 12, 0) and {
last = it.children.at(it.children.len() - 2)
last.at("label", default: none) == lbl-tag
}
)
} {
last.value
} else {
(data-kind: "content", body: it, fields: it.fields(), func: it.func(), eid: none, fields-known: false, valid: false)
}
} else if (
it.has("label")
and str(it.label).starts-with(lbl-outer-head)
) {
(data-kind: "incomplete-element-instance", body: it, fields: (:), func: (:), eid: str(it.label).slice(lbl-outer-head.len()), fields-known: false, valid: false)
} else {
(data-kind: "content", body: it, fields: it.fields(), func: it.func(), eid: none, fields-known: false, valid: false)
}
}
// Obtain the fields of a type instance or element instance (custom or not).
//
// SAMPLE USAGE:
//
// #show e.selector(elem): it => {
// let fields = e.fields(it)
// [Hello #fields.name!]
// }
#let fields(it) = {
let info = data(it)
if type(info) == dictionary and "data-kind" in info {
if info.data-kind in ("content", "element-instance", "type-instance") {
return info.fields
}
}
(:)
}
// Obtain context at an element's site.
//
// SAMPLE USAGE:
//
// 1. In show rules:
//
// #show e.selector(elem): it => {
// let (get, ..) = e.ctx(it)
// let other-elem-ctx = get(other-elem)
// [The other element field was set to #other-elem-ctx.field at that point!]
// }
//
// 2. In element declarations:
//
// #e.element.declare(
// ...
// synthesize: it => {
// // Get context for other element
// it.some-field = (e.ctx(it).get)(other-elem).field
// },
// ...
// )
#let ctx(it) = {
let info = data(it)
if type(info) == dictionary and "ctx" in info {
info.ctx
} else {
none
}
}
// Obtain an element's or type's scope (usually a module with important definitions).
//
// SAMPLE USAGE:
//
// #let subelem = e.scope(elem).subelem
#let scope(it) = {
let info = data(it)
if type(info) == dictionary and "scope" in info {
info.scope
} else {
none
}
}
/// Obtain an element's or custom type's constructor function.
/// For native elements, this will be equivalent to `it.func()`.
///
/// Useful in custom element show rules, for example.
///
/// This is equivalent to `e.data(it).func`.
///
/// SAMPLE USAGE:
///
/// ```typ
/// #show selector.or(e.selector(elem1), e.selector(elem2)): it => {
/// // Will be either elem1 or elem2
/// let elem = e.func(it)
/// // 'elem == elem1' works, but comparing 'eid's is recommended
/// if e.eid(elem) == e.eid(elem1) {
/// [This is elem1]
/// } else {
/// [This is elem2]
/// }
/// }
/// ```
///
/// - it (any): element/custom type instance (or element/custom type itself) to get the constructor from
/// -> function | none
#let func(it) = {
let info = data(it)
if type(info) == dictionary and "func" in info {
info.func
} else {
none
}
}
/// Obtain an element's eid. It uniquely distinguishes this element from others,
/// even if they have the same name, by including both its prefix and name.
///
/// This is equivalent to `e.data(elem).eid`.
///
/// - elem (any): custom element (or an instance of it) to get 'eid' from
/// -> function | none
#let eid(it) = {
let info = data(it)
if type(info) == dictionary and "eid" in info {
info.eid
} else {
none
}
}
/// Obtain a custom type's tid. It uniquely distinguishes a custom type from
/// others, even if they have the same name, by including both its prefix and
/// name. Returns `none` on invalid input.
///
/// This is equivalent to `e.data(typ).tid`.
///
/// - typ (any): custom type (or an instance of it) to get 'tid' from
/// -> function | none
#let tid(it) = {
let info = data(it)
if type(info) == dictionary and "tid" in info {
info.tid
} else {
none
}
}
// Obtain an element's counter.
//
// USAGE:
//
// #context {
// [The element counter value is #e.counter(elem).get().first()]
// }
#let counter_(elem) = {
let info = data(elem)
if type(info) == dictionary and "data-kind" in info and (info.data-kind == "element" or info.data-kind == "element-instance") {
info.counter
} else {
assert(false, message: "elembic: e.counter: this is not an element")
}
}
/// Get the name of a content's constructor function as a string.
///
/// Returns 'none' on invalid input.
///
/// USAGE:
///
/// ```typ
/// assert.eq(func-name(my-elem()), "my-elem")
/// assert.eq(func-name([= abc]), "heading")
/// ```
///
/// - c (content): content to get the constructor function of
/// -> function | none
#let func-name(c) = {
if type(c) == function {
let func-data = data(c)
return if "name" in func-data {
func-data.name
} else {
none
}
} else if type(c) != content {
return none
}
let name = repr(c.func())
if c.func() in elem-funcs {
let element-data = data(c)
if "eid" in element-data and element-data.eid != none {
name = if "name" in element-data and type(element-data.name) == str { element-data.name } else { "unknown custom element" }
}
}
name
}
#let _letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-"
/// This is used to obtain a debug representation of custom elements and types.
///
/// Also supports native types (just calls `repr()` for them).
///
/// - value (any): value to represent
/// - depth (int): current depth (must start at 0, conservative limit of 10 for now)
/// -> str
#let repr_(value, depth: 0) = {
if depth >= 10 {
return repr(value)
}
let typename = ""
let value-type = type(value)
if value-type == content and value.func() in elem-funcs {
let value-data = data(value)
if "eid" in value-data and value-data.eid != none {
value = value-data.fields
value-type = dictionary
typename = if "name" in value-data and type(value-data.name) == str {
value-data.name
} else {
"unknown-element"
}
}
}
if value-type == dictionary {
let pairs = if typename != "" {
// Element fields => sort
value.pairs().sorted(key: ((k, _)) => k)
} else if custom-type-key in value {
let type-data = value.at(custom-type-key)
let id = type-data.id
typename = if "name" in id {
id.name
} else if id == "custom type" {
return if custom-type-data-key in value {
"custom-type(name: " + repr(value.name) + ", tid: " + repr(value.tid) + ")"
} else {
"custom-type()"
}
} else {
str(id)
}
type-data.fields.pairs().sorted(key: ((k, _)) => k)
} else {
value.pairs()
}
typename
"("
pairs.map(((k, v)) => {
if k.codepoints().all(c => c in _letters) {
k
} else {
repr(k)
}
": "
repr_(v, depth: depth + 1)
}).join(", ")
")"
} else if value-type == array {
"("
value.map(repr_.with(depth: depth + 1)).join(", ")
")"
} else {
repr(value)
}
}
/// Performs deep equality of values.
///
/// This is necessary to reliably compare instances of the same element or
/// custom type, as well as data structures containing them such as arrays
/// or dictionary, between different versions of the same element or type,
/// by recursively comparing `eid(a) == eid(b) and fields(a) == fields(b)`.
/// However, this is notably slower than Typst's built-in equality check.
///
/// - a (any): First value to compare.
/// - b (any): Second value to compare.
/// -> bool
#let eq(a, b) = {
if a == b {
return true
}
if type(a) != type(b) or type(a) not in (content, dictionary, array) {
return false
}
// Recursively compare until we find a 'false'
let stack = ((a, b),)
let fuel = 3000
while stack != () {
let (a, b) = stack.pop()
if a == b {
// Good!
continue
}
fuel -= 1
if fuel == 0 {
return false
}
// Of course, the types must match
let a-type = type(a)
let b-type = type(b)
if a-type != b-type {
return false
}
if a-type == array {
if a.len() != b.len() {
return false
}
stack += array.zip(a, b)
// Only have special checks for composed types and custom types and elements
// of same type
} else if (a-type == content or a-type == dictionary) and eid(a) == eid(b) and tid(a) == tid(b) {
if eid(a) != none or tid(a) != none or a-type == content and a.func() == b.func() {
// Same element id, compare their fields
a = fields(a)
b = fields(b)
a-type = type(a)
if a-type != type(b) {
return false
}
}
if a-type != dictionary or a.len() != b.len() {
// Fields were invalid, or content didn't have the same func
return false
}
for (key, a-val) in a {
if key not in b {
return false
}
stack.push((a-val, b.at(key),))
}
} else {
return false
}
}
// No checks failed
true
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,366 @@
#import "data.typ": type-key, custom-type-key, current-field-version, eq
#import "types/types.typ"
#let field-key = "__elembic_field"
#let fields-key = "__elembic_fields"
#let _missing() = {}
// Specifies an element field's properties.
#let field(
name,
type_,
doc: none,
required: false,
named: auto,
synthesized: false,
default: _missing,
folds: auto,
internal: false,
meta: (:),
) = {
assert(type(name) == str, message: "elembic: field: Field name must be a string, not " + str(type(name)))
let error-prefix = "elembic: field '" + name + "': "
assert(doc == none or type(doc) == str, message: error-prefix + "'doc' must be none or a string (add documentation)")
assert(type(synthesized) == bool, message: error-prefix + "'synthesized' must be a boolean (true: field is automatically synthesized and cannot be specified or overridden by the user; false: field can be manually specified and overridden by the user)")
assert(type(required) == bool, message: error-prefix + "'required' must be a boolean")
assert(folds == auto or type(folds) == bool, message: error-prefix + "'folds' must be a boolean or auto")
assert(folds != true or not required, message: error-prefix + "'folds' cannot be set to 'true' on required fields at the moment")
assert(type(internal) == bool, message: error-prefix + "'internal' must be a boolean")
assert(type(meta) == dictionary, message: error-prefix + "'meta' must be a dictionary")
assert(named == auto or type(named) == bool, message: error-prefix + "'named' must be a boolean or auto")
let typeinfo = {
let (res, value) = types.validate(type_)
assert(res, message: if not res { error-prefix + value } else { "" })
value
}
if not required and default == _missing {
let (res, value) = types.default(typeinfo)
assert(res, message: if not res { error-prefix + value } else { "" })
default = value
}
default = if required or synthesized {
// This value should be ignored in that case
auto
} else {
let (success, value) = types.cast(default, typeinfo)
if not success {
assert(false, message: error-prefix + value + "\n hint: given default for field had an incompatible type")
}
value
}
if folds == auto {
folds = not required
}
let fold = if folds and not synthesized and "fold" in typeinfo and typeinfo.fold != none {
assert(typeinfo.fold == auto or type(typeinfo.fold) == function, message: error-prefix + "type '" + typeinfo.name + "' doesn't appear to have a valid fold field (must be auto or function)")
let fold-default = if required {
// No field default
assert(false, message: "elembic: internal error: required field must not be foldable")
} else {
// Use the field default as starting point for folding
default
}
(
folder: typeinfo.fold,
default: fold-default,
)
} else {
none
}
if named == auto {
// Pos arg is generally required
named = not required
}
if synthesized and (required or not named) {
assert(false, message: error-prefix + "synthesized field cannot be required or positional, since it cannot be specified by the user")
}
(
(field-key): true,
version: current-field-version,
name: name,
doc: doc,
typeinfo: typeinfo,
default: default,
required: required,
synthesized: synthesized,
named: named,
fold: fold,
folds: folds,
internal: internal,
meta: meta,
)
}
#let parse-fields(fields, allow-unknown-fields: false) = {
assert(type(allow-unknown-fields) == bool, message: "elembic: element.fields: 'allow-unknown-fields' must be a boolean, not " + str(type(allow-unknown-fields)))
let required-pos-fields = ()
let optional-pos-fields = ()
let required-named-fields = ()
let optional-named-fields = ()
let all-fields = (:)
let user-named-fields = (:)
let foldable-fields = (:)
let user-fields = (:)
let synthesized-fields = (:)
for field in fields {
assert(type(field) == dictionary and field.at(field-key, default: none) == true, message: "elembic: element.fields: Invalid field received, please use the 'e.fields.field' constructor.")
assert(field.named or not field.required or optional-pos-fields == (), message: "elembic: element.fields: field '" + field.name + "' cannot be positional and required and appear after other positional but optional fields. Ensure there are only optional fields after the first positional optional field.")
assert(field.name not in all-fields, message: "elembic: element.fields: duplicate field name '" + field.name + "'")
if field.required {
if field.named {
required-named-fields.push(field)
} else {
required-pos-fields.push(field)
}
} else if field.named {
optional-named-fields.push(field)
} else {
optional-pos-fields.push(field)
}
if field.fold != none {
foldable-fields.insert(field.name, field.fold)
}
if field.synthesized {
synthesized-fields.insert(field.name, field)
} else {
user-fields.insert(field.name, field)
if field.named {
user-named-fields.insert(field.name, field)
}
}
all-fields.insert(field.name, field)
}
(
(fields-key): true,
version: current-field-version,
required-pos-fields: required-pos-fields,
optional-pos-fields: optional-pos-fields,
required-named-fields: required-named-fields,
optional-named-fields: optional-named-fields,
foldable-fields: foldable-fields,
user-named-fields: user-named-fields,
user-fields: user-fields,
all-fields: all-fields,
allow-unknown-fields: allow-unknown-fields,
)
}
// Generates an argument parser function with the given general error
// prefix (for listing missing fields) and per-field error prefix function
// (for an invalid field; receives the field name).
//
// You can customize 'field-term' to customize what the word "field" is
// in error messages. It should be either a string or a two-element
// array with (singular, plural). Setting 'typecheck: false' also fully
// disables typechecking.
//
// Parse arguments into a dictionary of fields and their casted values.
// By default, include required arguments and error if they are missing.
// Setting 'include-required' to false will error if they are present
// instead.
#let generate-arg-parser(
fields: none,
general-error-prefix: "",
field-error-prefix: _ => "",
field-term: "field",
typecheck: true,
) = {
assert(type(fields) == dictionary and fields-key in fields, message: "elembic: generate-arg-parser: please use 'parse-fields' to generate the fields input.")
assert(type(general-error-prefix) == str, message: "elembic: generate-arg-parser: 'general-error-prefix' must be a string")
assert(type(field-error-prefix) == function, message: "elembic: generate-arg-parser: 'field-error-prefix' must be a function receiving field name and returning string")
assert(type(typecheck) == bool, message: "elembic: generate-arg-parser: 'typecheck' must be a boolean, not " + str(type(typecheck)))
let (field-singular, field-plural) = if type(field-term) == str {
(field-term, field-term + "s")
} else if type(field-term) == array and field-term.len() == 2 and field-term.all(term => type(term) == str) {
field-term
} else {
assert(false, message: "elembic: generate-arg-parser: 'field-term' must either be a string (plural with 's') or a two-element array of strings (singular, plural).")
}
let (required-pos-fields, optional-pos-fields, required-named-fields, optional-named-fields, all-fields, user-fields, user-named-fields, allow-unknown-fields) = fields
let required-pos-fields-amount = required-pos-fields.len()
let optional-pos-fields-amount = optional-pos-fields.len()
let total-pos-fields-amount = required-pos-fields-amount + optional-pos-fields-amount
let all-pos-fields = required-pos-fields + optional-pos-fields
let has-required-fields = required-pos-fields-amount + required-named-fields.len() != 0
// If we allow unknown named fields, we still need to check whether a
// positional or synthesized field was accidentally specified as a named field.
let is-unknown-named-field = if allow-unknown-fields {
f => f in all-fields and f not in user-named-fields
} else {
f => f not in user-named-fields
}
// Disable typechecking anyway if all fields are 'any'
//
// Have a separate typecheck option so type information can be kept in fields
// even if typechecking is undesirable
// Note: we don't parse args for synthesized fields, so we can exclude them when
// checking whether we will typecheck when parsing args
let typecheck = typecheck and user-fields.values().any(f => f.typeinfo.type-kind != "any")
// Parse args (no typechecking)
let parse-args-no-typechecking(args, include-required: true) = {
let pos = args.pos()
if include-required and pos.len() < required-pos-fields-amount {
// Plural
let term = if required-pos-fields-amount - pos.len() == 1 { field-singular } else { field-plural }
return (false, general-error-prefix + "missing positional " + term + " " + fields.required-pos-fields.slice(pos.len()).map(f => "'" + f.name + "'").join(", "))
}
if pos.len() > if include-required { total-pos-fields-amount } else { optional-pos-fields-amount } {
let expected-arg-amount = if include-required { total-pos-fields-amount } else { optional-pos-fields-amount }
let excluding-required-hint = if include-required { "" } else { "\n hint: only optional fields are accepted here" }
return (false, general-error-prefix + "too many positional arguments, expected " + str(expected-arg-amount) + excluding-required-hint)
}
let named-args = args.named()
if include-required {
if required-named-fields.any(f => f.name not in named-args) {
let missing-fields = required-named-fields.filter(f => f.name not in named-args)
let term = if missing-fields.len() == 1 { field-singular } else { field-plural }
return (false, general-error-prefix + "missing required named " + term + " " + missing-fields.map(f => "'" + f.name + "'").join(", "))
}
} else if required-named-fields.any(f => f.name in named-args) {
let field = required-named-fields.find(f => f.name in named-args)
return (false, field-error-prefix(field.name) + "this " + field-singular + " cannot be specified here\n hint: only optional " + field-plural + " are accepted here")
}
// Here we simultaneously check for unknown fields and for positional fields
// being wrongly specified as named. If there are no positional fields and
// unknown fields are allowed, there is no point in doing this check.
if (not allow-unknown-fields or total-pos-fields-amount > 0) and named-args.keys().any(is-unknown-named-field) {
let field-name = named-args.keys().find(is-unknown-named-field)
let field = all-fields.at(field-name, default: none)
let expected-pos-hint = if field == none or field.named { "" } else { "\n hint: this " + field-singular + " must be specified positionally" }
let is-synthesized-hint = if field != none and field.synthesized { "\n hint: this " + field-singular + " is synthesized and cannot be specified manually" } else { "" }
return (false, general-error-prefix + "unknown named " + field-singular + " '" + field-name + "'" + expected-pos-hint + is-synthesized-hint)
}
let pos-fields = if include-required { all-pos-fields } else { optional-pos-fields }
let i = 0
for value in pos {
let pos-field = pos-fields.at(i)
named-args.insert(pos-field.name, value)
i += 1
}
(true, named-args)
}
// Parse args (with typechecking)
let parse-args(args, include-required: true) = {
let result = (:)
let pos = args.pos()
if include-required and pos.len() < required-pos-fields-amount {
// Plural
let term = if required-pos-fields-amount - pos.len() == 1 { field-singular } else { field-plural }
return (false, general-error-prefix + "missing positional " + term + " " + fields.required-pos-fields.slice(pos.len()).map(f => "'" + f.name + "'").join(", "))
}
let expected-arg-amount = if include-required { total-pos-fields-amount } else { optional-pos-fields-amount }
if pos.len() > expected-arg-amount {
let excluding-required-hint = if include-required { "" } else { "\n hint: only optional fields are accepted here" }
return (false, general-error-prefix + "too many positional arguments, expected " + str(expected-arg-amount) + excluding-required-hint)
}
let named-args = args.named()
if include-required and required-named-fields.any(f => f.name not in named-args) {
let missing-fields = required-named-fields.filter(f => f.name not in named-args)
let term = if missing-fields.len() == 1 { field-singular } else { field-plural }
return (false, general-error-prefix + "missing required named " + term + " " + missing-fields.map(f => "'" + f.name + "'").join(", "))
}
for (field-name, value) in named-args {
if allow-unknown-fields and field-name not in all-fields {
continue
}
let field = all-fields.at(field-name, default: none)
if field == none or field.synthesized or not field.named {
let expected-pos-hint = if field == none or field.named { "" } else { "\n hint: this " + field-singular + " must be specified positionally" }
let is-synthesized-hint = if field != none and field.synthesized { "\n hint: this " + field-singular + " is synthesized and cannot be specified manually" } else { "" }
return (false, general-error-prefix + "unknown named " + field-singular + " '" + field-name + "'" + expected-pos-hint + is-synthesized-hint)
}
if not include-required and field.required {
return (false, field-error-prefix(field-name) + "this " + field-singular + " cannot be specified here\n hint: only optional " + field-plural + " are accepted here")
}
let typeinfo = field.typeinfo
let kind = typeinfo.type-kind
if kind != "any" {
let (res, casted) = types.cast(value, typeinfo)
if not res {
return (false, field-error-prefix(field-name) + casted)
}
named-args.insert(field-name, casted)
}
}
let pos-fields = if include-required { all-pos-fields } else { optional-pos-fields }
let i = 0
for value in pos {
let pos-field = pos-fields.at(i)
let typeinfo = pos-field.typeinfo
let kind = typeinfo.type-kind
let casted = value
if kind != "any" {
let res
(res, casted) = types.cast(value, typeinfo)
if not res {
return (false, field-error-prefix(pos-field.name) + casted)
}
}
named-args.insert(pos-field.name, casted)
i += 1
}
(true, named-args)
}
if typecheck {
parse-args
} else {
parse-args-no-typechecking
}
}

View File

@@ -0,0 +1,430 @@
#import "data.typ": element-version, filter-key, special-data-values
// Check if an element instance satisfies a filter.
//
// Assumes this filter already accepts this element, so eid is not checked.
#let verify-filter(fields, eid: none, filter: none, ancestry: ()) = {
if filter == none {
return false
}
if eid == none {
assert(false, message: "elembic: element.verify-filter: eid must not be none")
}
if "__future" in filter and element-version <= filter.__future.max-version {
return (filter.__future.call)(fields, eid: eid, filter: filter, ancestry: ancestry, __future-version: element-version)
} else if filter.kind == "where" {
return eid == filter.eid and filter.fields.pairs().all(((k, v)) => k in fields and fields.at(k) == v)
} else if filter.kind == "where-any" {
return eid in filter.fields-any and filter.fields-any.at(eid).any(f => f.pairs().all(((k, v)) => k in fields and fields.at(k) == v))
} else if filter.kind == "custom" {
return (filter.elements == none or eid in filter.elements) and (filter.call)(
fields, eid: eid, ancestry: if filter.may-need-ancestry { ancestry } else { () }, __please-use-var-args: true
)
}
// Manually simulate a recursive algorithm.
// Normally, for OR(A, B), we could just call (verify(A), verify(B)), but
// recursive calls are limited and expensive.
// We instead do the following:
// - Have a stack of filters pending evaluation.
// - Have a stack of evaluation results (operands). This is only used for
// non-short circuiting operations (see below).
// - Each time a filter is pushed to the pending stack, we push its operands
// to the pending stack too, until the top of the stack has no further
// operands, and mark the filter as "visited" so we don't add its operands
// again. Note that operands are always pushed in reverse for short circuit
// to work, since we have to evaluate - thus pop from the end - each operand
// in its original order.
// - We evaluate each leaf filter (where or custom) and push their results to
// 'operands' (in reverse order, from last to first).
// - We then reach the filter that will use the latest N results from
// 'operands' and push that filter's evaluated result (e.g. AND of the latest
// two results) into 'operands'.
// - Repeat the process until the filter stack is empty (all evaluated) and
// operands has only a single element (for the root filter).
// - If operands is empty or has more than one element, something bad
// happened. Otherwise, its only remaining element is the evaluated result of
// the root filter.
//
// We also have an "op-stack" to indicate the latest operation whose operands
// were expanded into the filter stack.
//
// The idea is to allow short circuiting when the latest operation is an AND
// or OR. Otherwise, the operation in op-stack is only used to indicate the
// latest operation doesn't short-circuit.
//
// It works as follows: we store the filter stack state in "op-stack"
// whenever we push an operation, such as and, or, xor etc. When the latest
// pushed operation is an "and" and the current filter returned false, we
// immediately restore the filter state at the "and" (ignore its other
// operands) and assign its value to "false". If it was an "or", we do the
// same if the current filter returned true, assigning its value to true.
let filter-stack = (filter,)
let op-stack = ()
let operands = ()
while filter-stack != () {
let last = filter-stack.last()
// Expand the latest filter's children into the evaluation stack.
while (
last.at(filter-key) != "visited"
and ("__future" not in last or element-version > last.__future.max-version)
and "operands" in last
and last.operands != ()
) {
// Ensure we don't reach the parent operation until we have evaluated
// each child operation.
op-stack.push((last.kind, filter-stack.len() - 1))
filter-stack.last().at(filter-key) = "visited"
if "__subject" in filter {
// Ensure children filters apply to the same subject.
filter-stack += last.operands.map(op => if "__subject" in op { op } else { (..op, __subject: filter.__subject) }).rev()
} else {
// In reverse order to pop the first operand first.
filter-stack += last.operands.rev()
}
last = filter-stack.last()
}
let filter = filter-stack.pop()
let (kind,) = filter
let fields = fields
let eid = eid
let ancestry = ancestry
if "__subject" in filter {
(fields, eid, ancestry) = filter.__subject
}
let value = if "__future" in filter and element-version <= filter.__future.max-version {
(filter.__future.call)(fields, eid: eid, filter: filter, ancestry: ancestry, __future-version: element-version)
} else if kind == "where" {
eid == filter.eid and filter.fields.pairs().all(((k, v)) => k in fields and fields.at(k) == v)
} else if kind == "where-any" {
eid in filter.fields-any and filter.fields-any.at(eid).any(f => f.pairs().all(((k, v)) => k in fields and fields.at(k) == v))
} else if kind == "custom" {
(filter.elements == none or eid in filter.elements) and (filter.call)(
fields, eid: eid, ancestry: if filter.may-need-ancestry { ancestry } else { () }, __please-use-var-args: true
)
} else if kind == "within" {
// Expand 'within' filter into
// (ancestor 1 matches OR ancestor 2 matches OR ...)
if filter.elements == none or eid in filter.elements {
let (ancestor-filter,) = filter
let matching-ancestors = if "depth" in filter and filter.depth != none and filter.depth > 0 {
let total-depth = ancestry.len()
if total-depth >= filter.depth {
((total-depth - filter.depth, ancestry.at(total-depth - filter.depth)),)
} else {
()
}
} else if "max-depth" in filter and filter.max-depth != none and filter.max-depth > 0 {
let total-depth = ancestry.len()
if total-depth <= filter.max-depth {
ancestry.enumerate()
} else {
ancestry.enumerate().slice(total-depth - filter.max-depth)
}
} else {
ancestry.enumerate()
}
filter-stack.push(
(
(filter-key): true,
element-version: element-version,
kind: "or",
operands: matching-ancestors.map(((i, ancestor)) => (
..ancestor-filter,
// TODO: maybe don't clone the ancestry for each ancestor...
__subject: (eid: ancestor.eid, fields: ancestor.fields, ancestry: ancestry.slice(0, i))
)),
elements: ancestor-filter.elements,
// Since this is an internal filter, doesn't matter
ancestry-elements: (:),
may-need-ancestry: true,
)
)
// This filter won't be evaluated, but rather the pushed OR.
continue
}
// Invalid
false
} else if kind == "and" {
// Due to short-circuiting, a false would have failed earlier.
true
} else if kind == "or" {
// Due to short-circuiting, a true would have succeeded earlier.
false
} else if "operands" in filter {
let first-applied-operand = operands.len() - filter.operands.len()
// Operation requires N operands => take N operands from the top of the
// stack.
let applied-operands = operands.slice(first-applied-operand)
operands = operands.slice(0, first-applied-operand)
if kind == "not" {
assert(applied-operands.len() == 1, message: "elembic: element.verify-filter: expected one child filter for 'not'")
(filter.elements == none or eid in filter.elements) and not applied-operands.first()
} else if kind == "xor" {
assert(applied-operands.len() == 2, message: "elembic: element.verify-filter: expected two children filters for 'xor'")
// Here the order doesn't matter, since we always need to evaluate both
// XOR operands (no short-circuit).
applied-operands.first() != applied-operands.at(1)
} else {
assert(false, message: "elembic: element.verify-filter: unsupported filter kind '" + kind + "'\n\nhint: this might mean you're using packages depending on conflicting elembic versions. Please ensure your dependencies are up-to-date.")
}
} else {
assert(false, message: "elembic: element.verify-filter: unsupported or invalid filter kind '" + kind + "'\n\nhint: this might mean you're using packages depending on conflicting elembic versions. Please ensure your dependencies are up-to-date.")
}
if op-stack != () and op-stack.last().at(1) == filter-stack.len() {
// We have just evaluated this operation.
_ = op-stack.pop()
}
// Short-circuit: for certain operations, a specific value must stop all
// other operand filters from running.
let (current-op, op-pos) = if op-stack == () { (none, none) } else { op-stack.last() }
while current-op == "and" and not value or current-op == "or" and value {
filter-stack = filter-stack.slice(0, op-pos)
_ = op-stack.pop()
if op-stack == () {
current-op = none
op-pos = none
break
} else {
(current-op, op-pos) = op-stack.last()
}
}
if current-op not in ("and", "or") {
operands.push(value)
}
}
if operands.len() != 1 or op-stack != () {
assert(false, message: "elembic: element.verify-filter: filter didn't receive enough operands.")
}
operands.first()
}
#let multi-operand-filter(kind: "", arg-count: none) = (..args) => {
assert(args.named() == (:), message: "elembic: filters: invalid named arguments given to '" + kind + "' filter constructor.")
let filters = args.pos()
if arg-count != none and filters.len() != arg-count {
assert(false, message: "elembic: filters: must give exactly " + str(arg-count) + " arguments to a '" + kind + "' filter constructor.")
}
filters = filters.map(filter => {
if type(filter) == function {
filter = filter(__elembic_data: special-data-values.get-where)
}
assert(type(filter) == dictionary and filter-key in filter, message: "elembic: filters: invalid filter passed to '" + kind + "' constructor, please use 'custom-element.with(...)' to generate a filter.")
// Flatten "and", "or"
if filter.kind == kind and kind in ("and", "or") {
filter.operands
} else {
(filter,)
}
}).flatten()
let elements = none
if kind == "and" {
// Merge where filters as an optimization
let where-fields = (:)
let where-eid = none
let may-merge-filters = filters != ()
// Intersect elements.
// Start accepting all elements and narrow it down from there.
for filter in filters {
assert("elements" in filter, message: "elembic: filters.and: this filter operand is missing the 'elements' field; this indicates it comes from an element generated with an outdated elembic version. Please use an element made with an up-to-date elembic version.")
if elements == none {
elements = filter.elements
} else if filter.elements != none {
// Cannot add new elements, only remove non-shared elements.
for (eid, elem-data) in elements {
if eid not in filter.elements {
_ = elements.remove(eid)
}
}
}
if filter.kind == "where" and may-merge-filters {
if where-eid == none {
where-eid = filter.eid
} else if where-eid != filter.eid {
// More than one element to check will never match.
may-merge-filters = false
continue
}
let (eid, fields) = filter
if where-fields == (:) {
where-fields = fields
} else {
for (field, value) in fields {
if field in where-fields and value != where-fields.at(field) {
// and(elem.with(a: 1), elem.with(a: 2))
// impossible to match
may-merge-filters = false
break
}
where-fields.insert(field, value)
}
}
} else if may-merge-filters {
// Has a custom filter, don't merge
may-merge-filters = false
}
}
if may-merge-filters and where-eid != none {
// and(elem, elem.with(a: 0), elem.with(b: 1))
return (
(filter-key): true,
element-version: element-version,
kind: "where",
eid: where-eid,
fields: where-fields,
elements: ((where-eid): elements.at(where-eid)),
ancestry-elements: (:),
// For optimizations
may-need-ancestry: false,
)
}
// Ensure the filters won't match on the wrong elements.
// Workaround for e.filters.and_(custom, element)
filters = filters.map(f => f + (elements: elements,))
elements
} else if kind == "or" or kind == "xor" {
// Join together.
elements = (:)
let may-merge-filters = kind == "or" and filters != ()
let wheres = (:)
for filter in filters {
if "elements" in filter and filter.elements == none {
// OR(Any, ...) is always Any.
// For XOR, we still have to check all operands so this would also be
// Any.
elements = none
} else if "elements" not in filter or type(filter.elements) != dictionary {
assert(false, message: "elembic: filters: invalid operand filter received by '" + kind + "' filter constructor\n\nhint: this filter was likely constructed with an old elembic version. Please update your packages.")
} else if elements != none {
elements += filter.elements
}
if may-merge-filters and filter.kind in ("where", "where-any") {
for (eid, fields) in if filter.kind == "where-any" { filter.fields-any } else { ((filter.eid): (filter.fields,)) } {
if eid in wheres {
wheres.at(eid) += fields
} else {
wheres.insert(eid, fields)
}
}
} else if may-merge-filters {
// Not all "where"
may-merge-filters = false
}
}
if may-merge-filters {
return (
(filter-key): true,
element-version: element-version,
kind: "where-any",
fields-any: wheres,
elements: elements,
ancestry-elements: (:),
may-need-ancestry: false,
)
}
elements
} else if kind == "not" {
// No elements for NOT since it is unrestricted.
// User will have to restrict it manually.
none
} else {
assert(false, message: "elembic: filters: internal error: invalid kind '" + kind + "'")
}
(
(filter-key): true,
element-version: element-version,
kind: kind,
operands: filters,
elements: elements,
ancestry-elements: (:) + filters.map(f => f.at("ancestry-elements", default: none)).join(),
may-need-ancestry: filters.any(f => "may-need-ancestry" in f and f.may-need-ancestry),
)
}
#let or-filter = multi-operand-filter(kind: "or")
#let and-filter = multi-operand-filter(kind: "and")
#let not-filter = multi-operand-filter(kind: "not", arg-count: 1)
#let xor-filter = multi-operand-filter(kind: "xor", arg-count: 2)
#let custom-filter(callback) = {
assert(type(callback) == function, message: "elembic: filters.custom: 'callback' for custom filter must be a function (fields, eid: eid, ..) => bool.")
(
(filter-key): true,
element-version: element-version,
kind: "custom",
call: callback,
elements: none,
ancestry-elements: (:),
may-need-ancestry: true,
)
}
/// Filter that only matches when this element is inside another elembic element.
///
/// For example:
///
/// ```typ
/// #show: e.show_(e.filters.and(elem, e.filters.within(other-elem)), none)
///
/// #other-elem(elem[This element will be matched and removed])
/// #elem[This element stays, as it is not inside `other-elem`]
/// ```
///
/// - ancestor-filter (filter): a filter to match potential ancestors.
/// - depth (int): only match at this exact KNOWN depth.
/// - max-depth (int): only match up to this exact KNOWN depth.
/// -> filter
#let within-filter(ancestor-filter, depth: none, max-depth: none) = {
if type(ancestor-filter) == function {
ancestor-filter = ancestor-filter(__elembic_data: special-data-values.get-where)
}
assert(type(ancestor-filter) == dictionary and filter-key in ancestor-filter, message: "elembic: filters.within: invalid filter, please use 'custom-element.with(...)' to generate a filter.")
assert("elements" in ancestor-filter, message: "elembic: filters.within: the ancestor filter is missing the 'elements' field; this indicates it comes from an element generated with an outdated elembic version. Please use an element made with an up-to-date elembic version.")
assert(ancestor-filter.elements != (:), message: "elembic: filters.within: the ancestor filter appears to not be restricted to any elements and is thus impossible to match. It must apply to at least one element (potential ancestor). Consider using a different filter.")
assert(ancestor-filter.elements != none, message: "elembic: filters.within: the ancestor filter appears to apply to any element. It must apply to exactly one element (the one receiving the set rule). Consider using an 'and' filter, e.g. 'e.filters.within(e.filters.and(wibble, e.not(wibble.with(a: 10))))' instead of just 'e.filters.within(e.not(wibble.with(a: 10)))', to restrict it.")
assert(depth == none or max-depth == none, message: "elembic: filters.within: cannot specify both depth and max-depth (please pick one).")
assert(depth == none or type(depth) == int and depth > 0, message: "elembic: filters.within: 'depth' parameter must be a positive integer or 'none'.")
assert(max-depth == none or type(max-depth) == int and max-depth > 0, message: "elembic: filters.within: 'max-depth' parameter must be a positive integer or 'none'.")
(
(filter-key): true,
element-version: element-version,
kind: "within",
ancestor-filter: ancestor-filter,
depth: depth,
max-depth: max-depth,
elements: none,
ancestry-elements: (:) + ancestor-filter.elements + ancestor-filter.at("ancestry-elements", default: (:)),
may-need-ancestry: true,
)
}

View File

@@ -0,0 +1,12 @@
#import "element.typ": set_, data, apply, revoke, reset, named, filtered, cond-set, show_, style-modes, prepare-get as get, settings, elem-selector as selector, select, elem-query as query, ref_ as ref, prepare
#import "fields.typ": field
#import "filters.typ": within-filter as within
#import "pub/data.typ": *
#import "pub/element.typ"
#import "pub/filters.typ"
#import "pub/parsing.typ"
#import "pub/types.typ"
#import "pub/result.typ"
#import "pub/leaky.typ"
#import "pub/stateful.typ"
#import "pub/internal.typ"

View File

@@ -0,0 +1 @@
#import "../data.typ": fields, counter_ as counter, ctx, scope, func, func-name, eid, tid, repr_ as repr, eq

View File

@@ -0,0 +1,2 @@
// Public re-exports for element-related functions.
#import "../element.typ": declare

View File

@@ -0,0 +1 @@
#import "../filters.typ": or-filter as or_, and-filter as and_, not-filter as not_, xor-filter as xor, custom-filter as custom

View File

@@ -0,0 +1 @@
#import "../data.typ": lbl-show-head, lbl-meta-head, lbl-outer-head, lbl-counter-head, lbl-elem-prepare-check-head, lbl-empty-prepare-check, labelable-elem-figure-kind, lbl-ref-figure-kind-head, lbl-ref-figure-label-head, lbl-ref-figure, lbl-get, lbl-tag, lbl-rule-tag, lbl-old-rule-tag, lbl-special-rule-tag, lbl-data-metadata, lbl-stateful-mode, lbl-leaky-mode, lbl-normal-mode, lbl-auto-mode, lbl-global-select-head, prepared-rule-key, stored-data-key, element-key, element-data-key, element-meta-key, global-data-key, filter-key, special-rule-key, special-data-values, custom-type-key, custom-type-data-key, type-key, element-version, type-version, custom-type-version, current-field-version, style-modes

View File

@@ -0,0 +1,2 @@
#import "../element.typ": prepare-debug as debug-get, stateful-debug-get
#import "internal-constants.typ" as constants

View File

@@ -0,0 +1,8 @@
// Exports rules defaulting to leaky mode.
#import "../element.typ": leaky-set as set_, leaky-apply as apply, leaky-show as show_, leaky-revoke as revoke, leaky-reset as reset, leaky-cond-set as cond-set, leaky-settings as settings, leaky-toggle as toggle
/// Enable leaky mode by default.
#let enable = toggle.with(true)
/// Disable leaky mode by default.
#let disable = toggle.with(false)

View File

@@ -0,0 +1,2 @@
// Public re-exports for native type-related functions and constants.
#import "../types/native.typ": content_, auto_, none_, float_, function_, int_, array_, dict_, datetime_, duration_, color_, gradient_, str_, type_, bool_, relative_, ratio_, typeinfo, angle_, arguments_, bytes_, tiling, tiling_, version_, fraction_, length_, stroke_

View File

@@ -0,0 +1 @@
#import "../fields.typ": parse-fields, generate-arg-parser

View File

@@ -0,0 +1 @@
#import "../types/base.typ": ok, err, is-ok

View File

@@ -0,0 +1,8 @@
// Exports rules defaulting to stateful mode.
#import "../element.typ": toggle-stateful-mode as toggle, stateful-set as set_, stateful-apply as apply, stateful-show as show_, stateful-revoke as revoke, stateful-reset as reset, stateful-cond-set as cond-set, stateful-get as get, stateful-settings as settings
// Enable stateful mode.
#let enable = toggle.with(true)
// Disable stateful mode.
#let disable = toggle.with(false)

View File

@@ -0,0 +1,5 @@
// Public re-exports for type-related functions and constants.
#import "../types/base.typ": any, never, custom-type, typeid, typename, native-elem
#import "../types/types.typ": option, smart, union, paint, literal, exact, wrap, array_ as array, dict_ as dict, default, validate as typeinfo, typeof, cast, generate-cast-error
#import "../types/custom.typ": declare
#import "native.typ"

View File

@@ -0,0 +1,694 @@
// The shared fundamentals of the type system.
#import "../data.typ": data, type-key, custom-type-key, custom-type-data-key, repr_, func-name, type-version, eq, elem-funcs
// Typeinfo structure:
// - type-key: kind of type
// - version: 1
// - name: type name
// - input: list of native types / custom types of input
// - output: list of native types / custom types of output
// - data: data specific for this type key
// - check: none (only check inputs) or function x => bool
// - cast: none (input is unchanged) or function to convert input to output
// - error: none or function x => string to customize check failure message
// - default: empty array (no default) or singleton array => default value for this type
// - fold: none, auto (equivalent to (a, b) => a + b but more efficient) or function (prev, next) => folded value:
// determines how to combine two consecutive values of this type in the stylechain
#let base-typeinfo = (
(type-key): true,
type-kind: "base",
version: type-version,
name: "unknown",
input: (),
output: (),
data: none,
check: none,
cast: none,
error: none,
default: (),
fold: none,
)
// Top type
// input and output have "any".
#let any = (
..base-typeinfo,
type-kind: "any",
name: "any",
input: ("any",),
output: ("any",),
)
// Bottom type
// input and output are empty.
#let never = (
..base-typeinfo,
type-kind: "never",
name: "never",
input: (),
output: (),
)
// Any custom type
#let custom-type = (
..base-typeinfo,
(type-key): "custom type",
name: "custom type",
input: ("custom type",),
output: ("custom type",),
)
#let element(name, eid) = (
..base-typeinfo,
type-kind: "element",
name: "element '" + name + "'",
input: (content,),
output: (content,),
check: c => c.func() in elem-funcs and data(c).eid == eid,
data: (name: name, eid: eid),
error: c => "expected element " + name + ", found " + func-name(c),
)
#let native-elem(func) = {
assert(type(func) == function, message: "elembic: types.native-elem: expected native element constructor, got " + str(type(func)))
(
..base-typeinfo,
type-kind: "native-element",
name: "native element '" + repr(func) + "'",
input: (content,),
output: (content,),
check: if func in elem-funcs { c => c.func() == func and data(c).eid == none } else { c => c.func() == func },
data: (func: func),
error: c => "expected native element " + repr(func) + ", found " + func-name(c),
)
}
// Get the type ID of a value.
// This is usually 'type(value)', unless value has a custom type.
// In that case, it has the format '(tid: ..., name: ...)'.
// This is the format expected by 'input' and 'output' arrays.
#let typeid(value) = {
let value-type = type(value)
if value-type == dictionary and custom-type-key in value {
value-type = value.at(custom-type-key).id
}
value-type
}
// Returns the name of the value's type as a string.
#let typename(value) = {
let value-type = type(value)
if value-type == dictionary and custom-type-key in value {
let id = value.at(custom-type-key).id
if "name" in id {
id.name
} else {
str(id)
}
} else {
str(value-type)
}
}
// Make a unique element or type ID based on prefix and name.
//
// Uses a separator and a "bit stuffing" technique to ensure
// the separator sequence doesn't appear in either of the
// prefix or the name in the final ID.
#let id-separator = "_---_"
#let trimmed-separator = id-separator.trim("_", at: end)
#let unique-id(kind, prefix, name) = {
(
kind + "_"
) + prefix.replace(
trimmed-separator, trimmed-separator + "-"
) + id-separator + name.replace(
trimmed-separator, trimmed-separator + "-"
)
}
// Literal type
// Only accepted if value is equal to the literal.
// Input and output are equal to the value.
//
// Uses base typeinfo information for information such as casts and whatnot.
#let literal(value, typeinfo) = {
let represented = "'" + if type(value) == str { value } else { repr_(value) } + "'"
let value-type = typeid(value)
let check = if typeinfo.check == none { x => eq(x, value) } else { x => eq(x, value) and (typeinfo.check)(x) }
(
..typeinfo,
type-kind: "literal",
name: "literal " + represented,
data: (value: value, typeinfo: typeinfo, represented: represented),
check: check,
error: _ => "given value wasn't equal to literal " + represented,
default: (value,),
)
}
// Union type (one of many)
// Data is the list of typeinfos.
// Accepted if the value corresponds to one of the given types.
// Does not check the validity of typeinfos.
#let union(typeinfos) = {
// Flatten nested unions
let typeinfos = typeinfos.map(t => if t.type-kind == "union" { t.data } else { (t,) }).sum(default: ()).dedup()
if typeinfos == () {
// No inputs accepted...
return never
}
if typeinfos.len() == 1 {
// Simplify union if there's nothing else
return typeinfos.first()
}
if typeinfos.any(x => x.type-kind == "any") {
// Union with 'any' is just any
return any
}
let name = typeinfos.map(t => t.name).join(", ", last: " or ")
let input = typeinfos.map(t => t.input).sum(default: ()).dedup()
let output = typeinfos.map(t => t.output).sum(default: ()).dedup()
let has-any-input = "any" in input
let has-any-output = "any" in output
if has-any-input {
input = ("any",)
}
if has-any-output {
output = ("any",)
}
// Try to optimize checks as much as possible
let check = if typeinfos.all(t => t.check == none) {
// If there are no checks, just checking inputs is enough
none
} else {
let checked-types = typeinfos.filter(t => t.check != none)
let unchecked-inputs = typeinfos.filter(t => t.check == none).map(t => t.input).sum(default: ()).dedup()
if input.all(t => t in unchecked-inputs) {
// Unchecked types include all possible input types, so some check will always succeed
// Note that this check also works for input reduced to just "any". If "any" is an
// unchecked input, then checks will never fail.
none
} else if checked-types.all(t => t.type-kind == "native-element" and ("__future_cast" not in t or t.__future_cast.max-version < type-version)) {
// From here onwards, we can assume unchecked-inputs doesn't contain "any",
// since it is a subset of input, therefore input would be just ("any",) and
// the check above would have had to pass in that case.
let all-funcs = checked-types.map(t => t.data.func)
let non-elem-funcs = all-funcs.filter(f => f not in elem-funcs)
let has-elem-func = elem-funcs.any(f => f in all-funcs)
// Check sequence separately, as a sequence can also be a custom element,
// so we must tell them apart.
if has-elem-func {
if non-elem-funcs == () {
x => {
let typ = type(x)
if typ == dictionary and custom-type-key in x {
// Custom type must be checked differently in inputs
typ = x.at(custom-type-key).id
}
typ in unchecked-inputs or typ == content and x.func() in elem-funcs and data(x).eid == none
}
} else {
x => {
let typ = type(x)
if typ == dictionary and custom-type-key in x {
// Custom type must be checked differently in inputs
typ = x.at(custom-type-key).id
}
typ in unchecked-inputs or typ == content and (x.func() in non-elem-funcs or x.func() in elem-funcs and data(x).eid == none)
}
}
} else {
x => {
let typ = type(x)
if typ == dictionary and custom-type-key in x {
// Custom type must be checked differently in inputs
typ = x.at(custom-type-key).id
}
typ in unchecked-inputs or typ == content and x.func() in non-elem-funcs
}
}
} else if checked-types.all(t => t.type-kind == "element" and ("__future_cast" not in t or t.__future_cast.max-version < type-version)) {
let all-eids = checked-types.map(t => t.data.eid)
x => {
let typ = type(x)
if typ == dictionary and custom-type-key in x {
// Custom type must be checked differently in inputs
typ = x.at(custom-type-key).id
}
typ in unchecked-inputs or typ == content and x.func() in elem-funcs and data(x).eid in all-eids
}
} else if checked-types.all(t => t.type-kind == "literal" and ("__future_cast" not in t or t.__future_cast.max-version < type-version)) {
let values-inputs-and-checks = checked-types.map(t => (t.data.value, t.input, t.data.typeinfo.check))
x => {
let typ = type(x)
if typ == dictionary and custom-type-key in x {
// Custom type must be checked differently in inputs
typ = x.at(custom-type-key).id
}
typ in unchecked-inputs or values-inputs-and-checks.any(((v, i, check)) => eq(x, v) and (typ in i or "any" in i) and (check == none or check(x)))
}
} else {
// If any check succeeds and the value has the correct input type, OK
let checks-and-inputs = checked-types.map(t => (t.input, t.check))
x => {
let typ = type(x)
if typ == dictionary and custom-type-key in x {
// Custom type must be checked differently in inputs
typ = x.at(custom-type-key).id
}
// If one of the types without checks accepts this type as an input then we don't need
// to run any checks!
typ in unchecked-inputs or checks-and-inputs.any(((inp, check)) => (typ in inp or "any" in inp) and check(x))
}
}
}
// Try to optimize casts
let cast = if typeinfos.all(t => t.cast == none) {
none
} else {
let casting-types = typeinfos.filter(t => t.cast != none)
let first-casting-type = casting-types.first()
if (
// If the casting types are all native, and none of the types before them
// accept their "cast-from" types, then we can fast track to a simple check:
// if within the 'cast-from' types, then cast, otherwise don't.
casting-types != ()
and casting-types.all(t => t.type-kind == "native" and t.data in (float, content) and ("__future_cast" not in t or t.__future_cast.max-version < type-version))
and typeinfos.find(t => t.input.any(i => i == "any" or i in first-casting-type.input)) == first-casting-type
and (casting-types.len() == 1 or typeinfos.find(t => t.input.any(i => i == "any" or i in casting-types.at(1).input)) == casting-types.at(1))
) {
if casting-types.len() >= 2 { // just float and content
x => if type(x) == int { float(x) } else if x == none or type(x) in (str, symbol) [#x] else { x }
} else if first-casting-type.data == float { // just float
x => if type(x) == int { float(x) } else { x }
} else { // just content
x => if x == none or type(x) in (str, symbol) { [#x] } else { x }
}
} else {
// Generic case
x => {
let typ = type(x)
if typ == dictionary and custom-type-key in x {
// Custom type must be checked differently in inputs
typ = x.at(custom-type-key).id
}
let typeinfo = typeinfos.find(t => (typ in t.input or "any" in t.input) and (t.check == none or (t.check)(x)))
if typeinfo.cast == none {
x
} else {
(typeinfo.cast)(x)
}
}
}
}
let error = if typeinfos.all(t => t.error == none) {
none
} else if typeinfos.all(t => t.type-kind == "literal" and ("__future_cast" not in t or t.__future_cast.max-version < type-version)) {
let literals = typeinfos.map(t => str(t.data.represented)).join(", ", last: " or ")
let message = "given value wasn't equal to literals " + literals
x => message
} else if typeinfos.all(t => t.type-kind == "native-element" and ("__future_cast" not in t or t.__future_cast.max-version < type-version)) {
let funcs = typeinfos.map(t => repr(t.data.func)).join(", ", last: " or ")
let head = "expected native elements " + funcs + ", found "
x => head + {
if type(x) == content { func-name(x) } else { "a(n) " + typename(x) }
}
} else if typeinfos.all(t => (t.type-kind == "element" or t.type-kind == "native-element") and ("__future_cast" not in t or t.__future_cast.max-version < type-version)) {
let funcs = typeinfos.map(t => if t.type-kind == "element" { t.data.name } else { repr(t.data.func) + " (native)" }).join(", ", last: " or ")
let head = "expected elements " + funcs + ", found "
x => head + {
if type(x) == content { func-name(x) } else { "a(n) " + typename(x) }
}
} else {
let error-types = typeinfos.filter(t => t.error != none)
x => {
"all typechecks for union failed" + error-types.filter(t => typeid(x) in t.input).map(t => "\n hint (" + t.name + "): " + (t.error)(x)).sum(default: "")
}
}
let is-option = typeinfos.first().type-kind == "native" and typeinfos.first().data == type(none)
let is-smart = typeinfos.first().type-kind == "native" and typeinfos.first().data == type(auto)
if is-smart != is-option and typeinfos.len() == 3 and typeinfos.at(1).type-kind == "native" and typeinfos.at(1).data in (type(none), type(auto)) {
// Both first types are (none, auto)
is-smart = true;
is-option = true;
}
let default = if is-option or is-smart {
// Default of 'none' for option(...)
// Default of 'auto' for smart(...)
typeinfos.first().default
} else {
()
}
let fold = if typeinfos.all(t => t.fold == none) or typeinfos.any(t => "any" in t.output) {
// We'd like to handle folding with "any" output in the future, but for now, let's not
none
} else if (is-option or is-smart) and typeinfos.len() == 2 and typeinfos.last().fold != none {
// Match built-in behavior by only folding option(T) or smart(T) if T can fold and the inner isn't explicitly none/auto
let other-typeinfo = typeinfos.at(1)
let other-fold = other-typeinfo.fold
if is-option {
if other-fold == auto {
(outer, inner) => if inner != none and outer != none { outer + inner } else { inner }
} else {
(outer, inner) => if inner != none and outer != none { other-fold(outer, inner) } else { inner }
}
} else {
if other-fold == auto {
(outer, inner) => if inner != auto and outer != auto { outer + inner } else { inner }
} else {
(outer, inner) => if inner != auto and outer != auto { other-fold(outer, inner) } else { inner }
}
}
} else if is-option and is-smart and typeinfos.len() == 3 and typeinfos.last().fold != none {
// smart(option(T))
// and option(smart(T))
let other-typeinfo = typeinfos.last()
let other-fold = other-typeinfo.fold
if other-fold == auto {
(outer, inner) => if inner != none and inner != auto and outer != none and outer != auto { outer + inner } else { inner }
} else {
(outer, inner) => if inner != none and inner != auto and outer != none and outer != auto { other-fold(outer, inner) } else { inner }
}
} else {
// Arbitrary union folding is allowed if the different casted values would
// belong to the same union type.
// Otherwise, can't do much if e.g. an int could be typeinfo A (say, positive integer)
// or typeinfo B (say, negative integer) because checks apply to inputs and not outputs
// (unless, of course, there is no casting).
// However, if there is only one typeinfo with a given output type, it is
// not ambiguous and may fold.
let unsure-outputs = () // array of (output type)
let unsure-output-data = () // array of ((i, fold, output))
let ambiguous-outputs = () // array of (output type)
for (i, typeinfo) in typeinfos.enumerate() {
for output in typeinfo.output.dedup() {
// NOTE: we assume 'output != "any"' as we filter that out in a
// previous conditional.
if output in unsure-outputs {
ambiguous-outputs.push(output)
// Invariant: there are no duplicate output types in 'unsure-outputs'.
// The only time we push to unsure-outputs is after this check fails,
// so that is always true.
let output-index = unsure-outputs.position(t => t == output)
_ = unsure-outputs.remove(output-index)
_ = unsure-output-data.remove(output-index)
} else if output != "never" and output not in ambiguous-outputs {
unsure-outputs.push(output)
unsure-output-data.push((i, typeinfo.fold, output))
}
}
}
// No conflicts found for those types
let unambiguous-outputs = unsure-output-data
let func(outer, inner) = {
let outer-id = typeid(outer)
let inner-id = typeid(inner)
let outer-output = unambiguous-outputs.find(((_, _, output)) => outer-id == output)
let inner-output = unambiguous-outputs.find(((_, _, output)) => inner-id == output)
let folder = if inner-output == none or outer-output == none or outer-output.first() != inner-output.first() {
// They belong to different types in the union, so no folding can be
// performed, even if they have the same fold function, since it still
// expects them to have the appropriate type.
//
// This branch is also reached when neither type is found. This
// suggests both belong to ambiguous output types and must not
// be folded.
return inner
} else {
// They belong to the same type with the same fold
outer-output.at(1)
}
if folder == none {
inner
} else if folder == auto {
// Caution: addition order matters!
// Could be arrays, for example.
outer + inner
} else {
folder(outer, inner)
}
}
if unambiguous-outputs == () {
none
} else {
func
}
}
(
..base-typeinfo,
type-kind: "union",
name: name,
data: typeinfos,
input: input,
output: output,
check: check,
cast: cast,
error: error,
default: default,
fold: fold,
)
}
// A result to indicate success and return a value.
#let ok(value) = {
(true, value)
}
// A result to indicate failure, with an error value indicating what happened.
#let err(error) = {
(false, error)
}
// Whether this result was successful.
#let is-ok(result) = {
type(result) == array and result.len() == 2 and result.first() == true
}
// Wrap a typeinfo with some other data.
// Mostly unchecked variant of 'types.wrap'.
#let wrap(typeinfo, overrides) = {
(
(..typeinfo, type-kind: "wrapped", data: (base: typeinfo, extra: none))
+ for (key, default) in base-typeinfo {
if key == type-key or key == "type-kind" {
continue
}
if key in overrides {
let override = overrides.at(key)
if key == "data" {
(data: (base: typeinfo, extra: override))
} else {
((key): override)
}
}
}
)
}
// A particular collection of types.
#let collection(name, base, parameters, check: none, cast: none, error: none, ..args) = {
if check == none {
check = base.check
}
if cast == none {
cast = base.cast
}
if check == none and error == none {
error = base.error
}
let other-args = args.named()
let default = if "default" in other-args {
other-args.default
} else {
base.default
}
let fold = if "fold" in other-args {
other-args.fold
} else {
base.fold
}
(
..base,
type-kind: "collection",
name: name + if parameters != () { " of " + parameters.map(t => t.name).join(", ", last: " and ") },
data: (base: base, parameters: parameters),
check: check,
cast: cast,
error: error,
default: default,
fold: fold,
)
}
// Create an array collection with a uniform parameter typeinfo for its elements.
#let array_(base-type, param, error: none) = {
assert(array in base-type.input or "any" in base-type.input)
let kind = param.type-kind
collection(
"array",
base-type,
(param,),
check: if param.check == none and "any" in param.input {
none
} else if param.input == () {
// Propagate 'never'
_ => false
} else if "any" in param.input {
// Only need to run checks
a => a.all(param.check)
} else {
// Some optimizations ahead
// The proper code is at the bottom
let input = param.input
let check = param.check
if kind == "native" and param.data == dictionary and ("__future_cast" not in param or param.__future_cast.max-version < type-version) {
a => a.all(x => type(x) == dictionary and custom-type-key not in x)
} else if param.input.all(i => type(i) == type) and dictionary not in param.input {
// No custom types accepted (the check above excludes '(tid: ..., name: ...)' as well as "any")
// If this is a custom type, it will return type(x) = dictionary, so it will fail
// (Also excludes "custom type": the type of custom types)
// So that suffices
if input.len() == 1 {
let input = input.first()
if check == none {
a => a.all(x => type(x) == input)
} else {
a => a.all(x => type(x) == input and check(x))
}
} else if input.len() == 2 {
let first = input.first()
let second = input.at(1)
if check == none {
a => a.all(x => type(x) == first or type(x) == second)
} else {
a => a.all(x => (type(x) == first or type(x) == second) and check(x))
}
} else if check == none {
a => a.all(x => type(x) in input)
} else {
a => a.all(x => type(x) in input and check(x))
}
} else if param.check == none {
a => a.all(x => typeid(x) in param.input)
} else {
a => a.all(x => typeid(x) in param.input and check(x))
}
},
cast: if param.cast == none {
none
} else if kind == "native" and param.data == content and ("__future_cast" not in param or param.__future_cast.max-version < type-version) {
a => a.map(x => [#x])
} else {
a => a.map(param.cast)
},
error: error
)
}
// Create a dict with a uniform parameter typeinfo for its values.
// (Keys are always strings.)
#let dict_(base-type, param, error: none) = {
assert(dictionary in base-type.input or "any" in base-type.input)
let kind = param.type-kind
collection(
"dict",
base-type,
(param,),
check: {
// Simply check the array of values
// (We can pass 'any' as the base type since that doesn't affect the 'check')
let array-check = array_(any, param).check
if array-check == none {
none
} else {
d => array-check(d.values())
}
},
cast: if param.cast == none {
none
} else if kind == "native" and param.data == content and ("__future_cast" not in param or param.__future_cast.max-version < type-version) {
d => {
for (k, v) in d {
d.at(k) = [#v]
}
d
}
} else {
let cast = param.cast
d => {
for (k, v) in d {
d.at(k) = cast(v)
}
d
}
},
error: error,
fold: if param.fold == none {
base-type.fold
} else if param.fold == auto {
(outer, inner) => {
let combined = outer + inner
for (k, v) in outer {
if k in inner {
combined.at(k) = v + inner.at(k)
}
}
combined
}
} else if type(param.fold) == function {
(outer, inner) => {
let combined = outer + inner
for (k, v) in outer {
if k in inner {
combined.at(k) = (param.fold)(v, inner.at(k))
}
}
combined
}
} else {
assert(false, message: "elembic: types.dict: parameter didn't have a valid fold function")
}
)
}

View File

@@ -0,0 +1,428 @@
// Custom types!
#import "../data.typ": special-data-values, custom-type-key, custom-type-data-key, type-key, custom-type-version
#import "base.typ"
#import "types.typ"
#import "../fields.typ" as field-internals
// Default folding procedure for custom types.
// Combines each inner type individually.
#let auto-fold(foldable-fields) = if foldable-fields == (:) {
// No fields to fold, so 'inner' always fully overwrites 'outer'.
// In that case, we can just sum inner with outer, adding its fields
// on top.
auto
} else {
(outer, inner) => {
let combined = outer + inner
for (field-name, fold-data) in foldable-fields {
if field-name in inner {
let outer = outer.at(field-name, default: fold-data.default)
if fold-data.folder == auto {
combined.at(field-name) = outer + inner.at(field-name)
} else {
combined.at(field-name) = (fold-data.folder)(outer, inner.at(field-name))
}
}
}
combined
}
}
#let auto-cast(from, fields: (:), constructor: none) = {
if from == dictionary {
value => constructor(..value)
} else {
assert(false, message: "elembic: types.auto-cast: invalid auto cast type: 'from' must be dictionary.")
}
}
#let auto-cast-check(from, fields: (:), parse-args: none) = {
if from == dictionary {
value => parse-args(arguments(..value)).first()
} else {
assert(false, message: "elembic: types.auto-cast: invalid auto cast type: 'from' must be dictionary.")
}
}
#let auto-cast-error(from, fields: (:), parse-args: none) = {
if from == dictionary {
value => parse-args(arguments(..value)).at(1)
} else {
assert(false, message: "elembic: types.auto-cast: invalid auto cast type: 'from' must be dictionary.")
}
}
#let declare(
name,
fields: none,
prefix: none,
doc: none,
default: none,
parse-args: auto,
typecheck: true,
allow-unknown-fields: false,
construct: none,
scope: none,
casts: none,
fold: auto,
) = {
let fields-hint = if type(fields) == dictionary { "\n hint: check if you didn't forget to add a trailing comma for a single field: write 'fields: (field,)', not 'fields: (field)'" } else { "" }
let casts-hint = if type(casts) == dictionary { "\n hint: check if you didn't forget to add a trailing comma for a single cast: write 'casts: ((from: ..., with: ...),)', not 'casts: ((from: ..., with: ...))'" } else { "" }
assert(type(fields) == array, message: "elembic: types.declare: please specify an array of fields, creating each field with the 'field' function." + fields-hint)
assert(prefix != none, message: "elembic: types.declare: please specify a 'prefix: ...' for your type, to distinguish it from types with the same name. If you are writing a package or template to be used by others, please do not use an empty prefix.")
assert(type(prefix) == str, message: "elembic: types.declare: the prefix must be a string, not '" + str(type(prefix)) + "'")
assert(doc == none or type(doc) == str, message: "elembic: types.declare: 'doc' must be none or a string (add documentation)")
assert(parse-args == auto or type(parse-args) == function, message: "elembic: types.declare: 'parse-args' must be either 'auto' (use built-in parser) or a function (default arg parser, fields: dictionary, typecheck: bool) => (user arguments, include-required: true) => (bool (true on success, false on error), dictionary with parsed fields (or error message string if the bool is false)).")
assert(type(typecheck) == bool, message: "elembic: types.declare: the 'typecheck' argument must be a boolean (true to enable typechecking in the constructor, false to disable).")
assert(type(allow-unknown-fields) == bool, message: "elembic: types.declare: the 'allow-unknown-fields' argument must be a boolean.")
assert(construct == none or type(construct) == function, message: "elembic: types.declare: 'construct' must be 'none' (use default constructor) or a function receiving the original constructor and returning the new constructor.")
assert(default == none or type(default) == function, message: "elembic: types.declare: 'default' must be none or a function receiving the constructor and returning the default.")
assert(scope == none or type(scope) in (dictionary, module), message: "elembic: types.declare: 'scope' must be either 'none', a dictionary or a module")
assert(
casts == none
or type(casts) == array and casts.all(
d => (
type(d) == dictionary
and "from" in d
and d.keys().all(k => k in ("from", "with", "check"))
and ("with" not in d or type(d.with) == function)
and ("check" not in d or d.check == none or type(d.check) == function)
)
),
message: "elembic: types.declare: 'casts' must be either 'none' or an array of dictionaries in the form (from: type, check (optional): none or casted value => bool, with (optional when 'from' is dictionary): constructor => casted value => your type)." + casts-hint
)
assert(fold == none or fold == auto or type(fold) == function, message: "elembic: types.declare: 'fold' must be 'none' (no folding), 'auto' (fold each field individually) or a function 'default constructor => auto (same as (a, b) => a + b but more efficient) or function (outer, inner) => combined value'.")
let type-args = (
name: name,
fields: fields,
prefix: prefix,
doc: doc,
default: default,
parse-args: parse-args,
typecheck: typecheck,
allow-unknown-fields: allow-unknown-fields,
construct: construct,
scope: scope,
casts: casts,
fold: fold,
)
let tid = base.unique-id("t", prefix, name)
let fields = field-internals.parse-fields(fields, allow-unknown-fields: allow-unknown-fields)
let (all-fields, user-fields, foldable-fields) = fields
let auto-fold = if fold == auto { auto-fold(foldable-fields) } else { none }
let default-arg-parser = field-internals.generate-arg-parser(
fields: fields,
general-error-prefix: "elembic: type '" + name + "': ",
field-error-prefix: field-name => "field '" + field-name + "' of type '" + name + "': ",
typecheck: typecheck
)
let parse-args = if parse-args == auto {
default-arg-parser
} else {
let parse-args = parse-args(default-arg-parser, fields: fields, typecheck: typecheck)
if type(parse-args) != function {
assert(false, message: "elembic: types.declare: 'parse-args', when specified as a function, receives the default arg parser alongside `fields: fields dictionary` and `typecheck: bool`, and must return a function (the new arg parser), and not " + base.typename(parse-args))
}
parse-args
}
let default-fields = fields.user-fields.values().map(f => if f.required { (:) } else { ((f.name): f.default) }).sum(default: (:))
let typeid = (tid: tid, name: name)
// We will specify default in a bit, once we declare the constructor
let typeinfo = (
..base.base-typeinfo,
type-kind: "custom",
name: name,
input: (typeid,),
output: (typeid,),
data: (
id: typeid,
// Original type before adding casts
// or none if this is already the type before casts
// (used for 'exact()')
pre-casts: none
)
)
let type-data = (
(custom-type-data-key): true,
(custom-type-key): (
data-kind: "type-instance",
fields: (
version: custom-type-version,
tid: tid,
id: typeid,
),
func: declare,
default-constructor: declare,
tid: "b_custom type",
id: "custom type",
fields-known: true,
valid: true
),
version: custom-type-version,
name: name,
doc: doc,
tid: tid,
id: typeid,
// We will add this here once the constructor is declared
typeinfo: none,
scope: scope,
parse-args: parse-args,
default-fields: default-fields,
user-fields: user-fields,
all-fields: all-fields,
fields: fields,
typecheck: typecheck,
allow-unknown-fields: allow-unknown-fields,
default-constructor: none,
func: none,
type-args: type-args,
)
let process-casts = if casts == none {
none
} else {
// Trick: We assign cast to each cast-from type and create a union,
// and use its generated check/cast functions as our own
default-constructor => {
let typeinfos = casts.map(cast => {
let (res, from) = types.validate(cast.from)
if not res {
assert(false, message: "elembic: types.declare: invalid cast-from type: " + from)
}
let (cast-check, with, cast-error) = if "with" in cast {
(cast.at("check", default: none), (cast.with)(default-constructor), none)
} else if from.type-kind == "native" and from.data == dictionary {
assert(fields.required-pos-fields == (), message: "elembic: types.declare: cannot generate automatic cast from dict when there are required positional fields.")
if "check" in cast {
(
cast.check,
auto-cast(dictionary, fields: fields, constructor: default-constructor),
none,
)
} else {
(
auto-cast-check(dictionary, fields: fields, parse-args: parse-args),
auto-cast(dictionary, fields: fields, constructor: default-constructor),
auto-cast-error(dictionary, fields: fields, parse-args: parse-args),
)
}
} else {
assert(
false,
message: "elembic: types.declare: cast 'with' can only be omitted for 'from: dictionary'. It must receive the default constructor and return a function 'casted value => your type'."
)
}
if type(with) != function {
assert(
false,
message: "elembic: types.declare: cast 'with' must receive the default constructor and return a function 'casted value => your type'. Received " + base.typename(with)
)
}
let from-cast = from.cast
types.wrap(
from,
check: from-check => if from-check == none {
if cast-check == none {
none
} else if from.cast == none {
cast-check
} else {
value => cast-check(from-cast(value))
}
} else if cast-check == none {
from-check
} else if from.cast == none {
value => from-check(value) and cast-check(value)
} else {
value => from-check(value) and cast-check(from-cast(value))
},
output: (typeid,),
cast: from-cast => if from-cast == none {
with
} else {
value => with(from-cast(value))
},
default: (),
fold: none,
..if cast-error == none { (:) } else { (
error: if "check" not in from or from.check == none {
_ => cast-error
} else {
from-error => value => if from-error == none or from-check(value) { cast-error(value) } else { from-error(value) }
},
) }
)
})
// Accept our own typeinfo first and foremost
let union = base.union((typeinfo,) + typeinfos)
assert(
union.output == (typeid,) and union.default == () and union.fold == none,
message: "elembic: types.declare: internal error: cast generated invalid union: " + repr(union)
)
(
input: union.input,
output: union.output,
check: union.check,
cast: union.cast,
error: if union.error == none {
_ => "failed to cast to custom type '" + name + "'"
} else {
x => (union.error)(x).replace("all typechecks for union failed", "all casts to custom type '" + name + "' failed")
},
data: typeinfo.data + (pre-casts: typeinfo)
)
}
}
let default-constructor(..args, __elembic_data: none, __elembic_func: auto) = {
if __elembic_func == auto {
__elembic_func = default-constructor
}
let default-constructor = default-constructor.with(__elembic_func: __elembic_func)
if __elembic_data != none {
return if __elembic_data == special-data-values.get-data {
let typeinfo = typeinfo + if process-casts != none { process-casts(default-constructor) } else { (:) }
if default != none {
typeinfo.default = (default(default-constructor),)
}
if auto-fold != none {
typeinfo.fold = auto-fold
} else if type(fold) == function {
let fold = fold(default-constructor)
if fold != auto and type(fold) != function {
assert(false, message: "elembic: types: custom type did not specify a valid fold, must be a function default constructor => value, got " + base.typename(fold))
}
typeinfo.fold = fold
}
(data-kind: "custom-type-data", ..type-data, typeinfo: typeinfo, func: __elembic_func, default-constructor: default-constructor)
} else {
assert(false, message: "elembic: types: invalid data key to constructor: " + repr(__elembic_data))
}
}
let (res, args) = parse-args(args, include-required: true)
if not res {
assert(false, message: args)
}
let final-fields = default-fields + args
if foldable-fields != (:) {
// Fold received arguments with defaults
for (field-name, fold-data) in foldable-fields {
if field-name in args {
let outer = default-fields.at(field-name, default: fold-data.default)
if fold-data.folder == auto {
final-fields.at(field-name) = outer + args.at(field-name)
} else {
final-fields.at(field-name) = (fold-data.folder)(outer, args.at(field-name))
}
}
}
}
final-fields.insert(
custom-type-key,
(
data-kind: "type-instance",
fields: final-fields,
func: __elembic_func,
default-constructor: default-constructor,
tid: tid,
id: (tid: tid, name: name),
scope: scope,
fields-known: true,
valid: true
)
)
final-fields
}
default = if default == none {
()
} else {
let default = default(default-constructor)
assert(
type(default) == dictionary and custom-type-key in default and default.at(custom-type-key).id == typeid,
message: "elembic: types.declare: the 'default' function must return an instance of the new type using the provided constructor, not " + repr(default)
)
(default,)
}
fold = if auto-fold != none {
auto-fold
} else if type(fold) == function {
let fold = fold(default-constructor)
if fold != auto and type(fold) != function {
assert(false, message: "elembic: types.declare: a valid fold was not specified, must be a function default constructor => value, got " + base.typename(fold))
}
fold
} else {
none
}
if process-casts != none {
typeinfo += process-casts(default-constructor)
}
typeinfo.default = default
typeinfo.fold = fold
type-data.typeinfo = typeinfo
let final-constructor = if construct != none {
{
let test-construct = construct(default-constructor)
assert(type(test-construct) == function, message: "elembic: types.declare: the 'construct' function must receive the default constructor and return the new constructor, a new function, not '" + str(type(test-construct)) + "'.")
}
let final-constructor(..args, __elembic_data: none) = {
if __elembic_data != none {
return if __elembic_data == special-data-values.get-data {
(data-kind: "custom-type-data", ..type-data, func: final-constructor, default-constructor: default-constructor.with(__elembic_func: final-constructor))
} else {
assert(false, message: "elembic: types: invalid data key to constructor: " + repr(__elembic_data))
}
}
construct(default-constructor.with(__elembic_func: final-constructor))(..args)
}
final-constructor
} else {
default-constructor
}
type-data.default-constructor = default-constructor.with(__elembic_func: final-constructor)
type-data.func = final-constructor
final-constructor
}

View File

@@ -0,0 +1,349 @@
// Typst-native types.
#import "../data.typ": type-key
#import "base.typ": base-typeinfo, ok, err
// Tiling type (renamed in Typst 0.13.0)
#let tiling = if sys.version < version(0, 13, 0) { pattern } else { tiling }
#let native-base = (
..base-typeinfo,
type-kind: "native",
)
// Generic typeinfo for a native type.
// PROPERTY: if type key is native, then output has the native type,
// and input has a list of native types that can be cast to it.
#let generic-typeinfo(native-type) = {
assert(type(native-type) == type(str), message: "elembic: internal error: not a type")
(
..native-base,
name: str(native-type),
input: (native-type,),
output: (native-type,),
data: native-type,
)
}
// Castable types
#let content_ = (
..native-base,
name: str(content),
input: (type(none), content, str, symbol),
output: (content,),
data: content,
cast: x => [#x],
default: ([],),
)
#let float_ = (
..native-base,
name: str(float),
input: (float, int),
output: (float,),
data: float,
cast: float,
default: (0.0,),
)
#let stroke-keys = ("paint", "thickness", "cap", "join", "dash", "miter-limit")
#let stroke_ = (
..native-base,
name: str(stroke),
input: (stroke, length, color, gradient, tiling, dictionary),
output: (stroke,),
data: stroke,
cast: stroke,
check: v => type(v) != dictionary or v.keys().all(k => k in stroke-keys),
default: (stroke(),),
// Allow specifying e.g. 4pt in one set rule, red in the other => 4pt + red in the end
fold: (outer, inner) => {
// Can't sum stroke with stroke, so can't optimize with 'fold: auto' :(
stroke(
paint: if inner.paint == auto { outer.paint } else { inner.paint },
thickness: if inner.thickness == auto { outer.thickness } else { inner.thickness },
cap: if inner.cap == auto { outer.cap } else { inner.cap },
join: if inner.join == auto { outer.join } else { inner.join },
dash: if inner.dash == auto { outer.dash } else { inner.dash },
miter-limit: if inner.miter-limit == auto { outer.miter-limit } else { inner.miter-limit },
)
},
)
#let relative_ = (
..native-base,
name: str(relative),
input: (relative, length, ratio),
output: (relative,),
data: relative,
cast: x => x + 0% + 0pt,
default: (0% + 0pt,),
)
#let function_ = (
..native-base,
name: str(function),
// Would add symbol as well, but missing a reliable way to check for callable symbols
input: (type, function),
output: (type, function,),
data: function,
)
// Folding types (also includes stroke above)
#let array_ = (
..native-base,
name: str(array),
input: (array,),
output: (array,),
data: array,
default: ((),),
// Array fields are joined together by default:
// set(field: (1, 2)), set(field: (3, 4))
// => set(field: (1, 2, 3, 4))
fold: auto,
)
#let dict_ = (
..native-base,
name: str(dictionary),
input: (dictionary,),
output: (dictionary,),
data: dictionary,
default: ((:),),
// Dictionary fields are joined together by default:
// set(field: (a: 12)), set(field: (b: 13))
// => set(field: (a: 12, b: 13))
fold: auto,
)
#let alignment_ = (
..native-base,
name: str(alignment),
input: (alignment,),
output: (alignment,),
data: alignment,
fold: (outer, inner) => if inner.axis() == none or outer.axis() == inner.axis() {
// If axis A == axis B, we override. For example, left -> right. (No sum)
// Same if both are none (2D alignments), in which case inner fully overrides as well (left + top -> center + bottom).
// In addition, if inner axis is none (it is a 2D alignment), it overrides in both ways (left -> right + top).
inner
} else if outer.axis() == none {
// Here, we know that inner isn't 2D, so either outer is 2D or both have different axes.
// If outer is 2D and inner is 1D, inner replaces its axis in outer, but the other axis is kept.
if inner.axis() == "horizontal" {
inner + outer.y
} else {
outer.x + inner
}
} else {
// Both are 1D and have distinct axes, so we just sum.
// left and top => left + top
// bottom and right => right + bottom
inner + outer
}
)
// Simple types (no casting)
#let str_ = (
..native-base,
name: str(str),
input: (str,),
output: (str,),
data: str,
default: ("",)
)
#let bool_ = (
..native-base,
name: str(bool),
input: (bool,),
output: (bool,),
data: bool,
default: (false,)
)
#let int_ = (
..native-base,
name: str(int),
input: (int,),
output: (int,),
data: int,
default: (0,),
)
#let color_ = (
..native-base,
name: str(color),
input: (color,),
output: (color,),
data: color,
)
#let gradient_ = (
..native-base,
name: str(gradient),
input: (gradient,),
output: (gradient,),
data: gradient,
)
#let tiling_ = (
..native-base,
name: str(tiling),
input: (tiling,),
output: (tiling,),
data: tiling,
)
#let datetime_ = (
..native-base,
name: str(datetime),
input: (datetime,),
output: (datetime,),
data: datetime,
)
#let angle_ = (
..native-base,
name: str(angle),
input: (angle,),
output: (angle,),
data: angle,
default: (0deg,),
)
#let ratio_ = (
..native-base,
name: str(ratio),
input: (ratio,),
output: (ratio,),
data: ratio,
default: (0%,),
)
#let length_ = (
..native-base,
name: str(length),
input: (length,),
output: (length,),
data: length,
default: (0pt,),
)
#let fraction_ = (
..native-base,
name: str(fraction),
input: (fraction,),
output: (fraction,),
data: fraction,
default: (0fr,),
)
#let duration_ = (
..native-base,
name: str(duration),
input: (duration,),
output: (duration,),
data: duration,
default: (duration(seconds: 0),),
)
#let type_ = (
..native-base,
name: str(type),
input: (type,),
output: (type,),
data: type,
)
#let arguments_ = (
..native-base,
name: str(arguments),
input: (arguments,),
output: (arguments,),
data: arguments,
default: (arguments(),),
)
#let bytes_ = (
..native-base,
name: str(bytes),
input: (bytes,),
output: (bytes,),
data: bytes,
default: (bytes(()),),
)
#let version_ = (
..native-base,
name: str(version),
input: (version,),
output: (version,),
data: version,
default: (version(0, 0, 0),),
)
// None / auto
#let none_ = (
..native-base,
name: "none",
input: (type(none),),
output: (type(none),),
data: type(none),
default: (none,)
)
#let auto_ = (
..native-base,
name: "auto",
input: (type(auto),),
output: (type(auto),),
data: type(auto),
default: (auto,)
)
// Return the typeinfo for a native type.
#let typeinfo(t) = {
let out = if t == content {
content_
} else if t == int {
int_
} else if t == bool {
bool_
} else if t == float {
float_
} else if t == type(none) {
none_
} else if t == type(auto) {
auto_
} else if t == dictionary {
dict_
} else if t == array {
array_
} else if t == str {
str_
} else if t == color {
color_
} else if t == gradient {
gradient_
} else if t == datetime {
datetime_
} else if t == duration {
duration_
} else if t == function {
function_
} else if t == relative {
relative_
} else if t == stroke {
stroke_
} else if t == tiling {
tiling_
} else if t == type {
type_
} else if t == angle {
angle_
} else if t == alignment {
alignment_
} else if t == ratio {
ratio_
} else if t == length {
length_
} else if t == fraction {
fraction_
} else if t == arguments {
arguments_
} else if t == bytes {
bytes_
} else if t == version {
version_
} else {
generic-typeinfo(t)
}
(true, out)
}

View File

@@ -0,0 +1,434 @@
// The type system used by fields.
#import "../data.typ": data, special-data-values, type-key, custom-type-key, custom-type-data-key, eq, type-version, elem-funcs
#import "base.typ" as base: ok, err
#import "native.typ"
// The default value for a type.
#let default(type_) = {
if type_.default == () {
let prefix = if type_.type-kind in ("native", "union") { type_.type-kind + " " } else { "" }
err(prefix + "type '" + type_.name + "' has no known default, please specify an explicit 'default: value' or set 'required: true' for the field")
} else {
ok(type_.default.first())
}
}
#let typeof(value) = {
let element-data
if type(value) == dictionary and custom-type-key in value {
if custom-type-data-key in value {
base.custom-type
} else {
(value.at(custom-type-key).func)(__elembic_data: special-data-values.get-data).typeinfo
}
} else if type(value) == content and value.func() in elem-funcs and {
element-data = data(value)
element-data.eid != none
} {
if "name" in element-data and type(element-data.name) == str {
base.element(element-data.name, element-data.eid)
} else {
base.element("unknown-element", element-data.eid)
}
} else {
let (res, typeinfo) = native.typeinfo(type(value))
if not res {
assert(false, message: "elembic: types.typeof: " + typeinfo)
}
typeinfo
}
}
// Literal type
// Only accepted if value is equal to the literal.
// Input and output are equal to the value.
//
// Uses base typeinfo information for information such as casts and whatnot.
#let literal(value) = {
if value == none {
native.none_
} else if value == auto {
native.auto_
} else {
base.literal(value, typeof(value))
}
}
// Obtain the typeinfo for a type.
//
// Returns ok(typeinfo), or err(error) if there is no corresponding typeinfo.
#let validate(type_) = {
if type(type_) == function {
let data = type_(__elembic_data: special-data-values.get-data)
let data-kind = data.at("data-kind", default: "unknown")
if data-kind == "custom-type-data" {
type_ = data.typeinfo
} else if data-kind == "element" {
type_ = base.element(data.name, data.eid)
} else {
return (false, "Received invalid type: " + repr(type_) + "\n hint: use 'types.literal(value)' to indicate only that particular value is valid")
}
}
if type(type_) == type {
native.typeinfo(type_)
} else if type(type_) == dictionary and type-key in type_ {
(true, type_)
} else if type(type_) == dictionary and custom-type-data-key in type_ {
(true, type_.typeinfo)
} else if type(type_) == function {
(false, "A function is not a valid type. (You can use 'types.literal(func)' to only accept a particular function.)")
} else if type_ == none or type_ == auto {
// Accept none or auto to mean their types
native.typeinfo(type(type_))
} else if type(type_) not in (dictionary, array, content) {
// Automatically accept literals
(true, literal(type_))
} else {
(false, "Received invalid type: " + repr(type_) + "\n hint: use 'types.literal(value)' to indicate only that particular value is valid")
}
}
// Error when a value doesn't conform to a certain cast
#let generate-cast-error(value, typeinfo, hint: none) = {
let message = if "any" not in typeinfo.input and base.typeid(value) not in typeinfo.input {
if typeinfo.input == () {
"type '" + typeinfo.name + "' does not accept any values"
} else {
(
"expected "
+ typeinfo.input.map(t => if type(t) == dictionary and "name" in t { t.name } else { str(t) }).join(", ", last: " or ")
+ ", found "
+ base.typename(value)
)
}
} else if typeinfo.at("error", default: none) != none {
(typeinfo.error)(value)
} else {
"typecheck for " + typeinfo.name + " failed"
}
let given-hint = if hint == none { "" } else { "\n hint: " + hint }
message + given-hint
}
// Try to accept value via given typeinfo or return error
// Returns ok(value) a.k.a. (true, value) on success
// Returns err(value) a.k.a. (false, value) on error
#let cast(value, typeinfo) = {
if type(typeinfo) != dictionary or type-key not in typeinfo {
let (res, typeinfo-or-err) = validate(typeinfo)
if not res {
assert(false, message: "elembic: types.cast: " + typeinfo-or-err)
}
typeinfo = typeinfo-or-err
}
let kind = typeinfo.type-kind
if kind == "any" {
(true, value)
} else {
let value-type = type(value)
if value-type == dictionary and custom-type-key in value {
value-type = value.at(custom-type-key).id
}
if kind == "literal" and typeinfo.cast == none and ("__future_cast" not in typeinfo or typeinfo.__future_cast.max-version < type-version) {
if eq(value, typeinfo.data.value) and (value-type in typeinfo.input or "any" in typeinfo.input) and (typeinfo.data.typeinfo.check == none or (typeinfo.data.typeinfo.check)(value)) {
(true, value)
} else {
(false, generate-cast-error(value, typeinfo))
}
} else if (
value-type not in typeinfo.input and "any" not in typeinfo.input
or typeinfo.check != none and not (typeinfo.check)(value)
) {
(false, generate-cast-error(value, typeinfo))
} else if typeinfo.cast == none {
(true, value)
} else if kind == "native" and typeinfo.data == content and ("__future_cast" not in typeinfo or typeinfo.__future_cast.max-version < type-version) {
(true, [#value])
} else {
(true, (typeinfo.cast)(value))
}
}
}
// Expected types for each typeinfo key.
#let overridable-typeinfo-types = (
name: (check: a => type(a) == str, error: "string or function old name => new name"),
input: (check: a => type(a) == array and a.all(x => x == "any" or x == "custom type" or type(x) == type or (type(x) == dictionary and "tid" in x)), error: "array of \"any\", \"custom type\", type, or custom type id (tid: ...), or function old input => new input"),
output: (check: a => type(a) == array and a.all(x => x == "any" or x == "custom type" or type(x) == type or (type(x) == dictionary and "tid" in x)), error: "array of \"any\", \"custom type\", type, or custom type id (tid: ...), or function old output => new output"),
check: (check: a => a == none or type(a) == function, error: "none or function receiving old function and returning a function value => bool"),
cast: (check: a => a == none or type(a) == function, error: "none or function receiving old function and returning a function checked input => output"),
error: (check: a => a == none or type(a) == function, error: "none or function receiving old function and returning a function checked input => error string"),
default: (check: d => d == () or type(d) == array and d.len() == 1, error: "empty array for no default, singleton array for one default, or function old default => new default"),
fold: (check: f => f == none or f == auto or type(f) == function, error: "none for no folding, auto to fold with sum (same as (a, b) => a + b), or function receiving old fold and returning either none or auto, or a new function (outer, inner) => combined value"),
)
// Wrap a type, altering its properties while keeping (or replacing) its input types and checks.
#let wrap(type_, ..data) = {
assert(data.pos() == (), message: "elembic: types.wrap: unexpected positional arguments")
let (res, typeinfo) = validate(type_)
if not res {
assert(false, message: "elembic: types.wrap: " + typeinfo)
}
let overrides = data.named()
for (key, value) in overrides {
let (check: validate-value, error: key-error) = overridable-typeinfo-types.at(key, default: (check: none, error: none))
if validate-value == none or key-error == none {
assert(false, message: "elembic: types.wrap: invalid key '" + key + "', must be one of " + overridable-typeinfo-types.keys().join(", ", last: " or "))
}
if type(value) == function {
value = value(typeinfo.at(key, default: base.base-typeinfo.at(key)))
overrides.at(key) = value
}
if not validate-value(value) {
let array-hint = if key in ("input", "output", "default") and type(value) != array {
"\n hint: did you forget a comma at (value,) and wrote (value) instead? Make sure an array was given."
} else {
""
}
assert(false, message: "elembic: types.wrap: invalid value for key '" + key + "', expected " + key-error + array-hint)
}
}
if "any" not in typeinfo.output and "cast" in overrides and "output" not in overrides {
// - If there is a cast and output is unchanged, then complain: please update the output
assert(false, message: "elembic: types.wrap: please override 'output' whenever overriding 'cast', specifying which types the cast function may return, or '(\"any\",)' (note the comma!) to indicate the cast function may return any type (discouraged, disables some optimizations).\n\nYou can also tell elembic you are sure the new cast function may produce strictly the same output types as before with 'output: prev => prev'.")
}
if typeinfo.cast != none and "output" in overrides and "cast" not in overrides and "any" not in overrides.output and typeinfo.output.any(o => o not in overrides.output) {
// If output was changed to a list which isn't 'any' and isn't a superset of the previous output,
// then ensure casting is also changed, as it is no longer safe (might produce something that is
// an invalid output)
assert(false, message: "elembic: types.wrap: a type output was removed, but its cast was not changed, meaning the cast function might produce a now invalid output. Fix this by either providing a new cast function, setting 'cast: none' to disable casting entirely, or setting 'cast: prev => prev' if you're sure the previous cast function cannot produce one of the removed output types.")
}
if "output" in overrides and "any" in overrides.output {
// - Collapse "any" + other types into just "any"
overrides.output = ("any",)
}
if "input" in overrides and "any" in overrides.input {
// - Collapse "any" + other types into just "any"
overrides.input = ("any",)
}
if "default" not in overrides and typeinfo.default != () and ("check" in overrides or "output" in overrides and "any" not in overrides.output and typeinfo.output.any(o => o not in overrides.output)) {
// Not sure if default would fit those criteria anymore:
// 1. By overriding the check, it's possible that a type such as positive int (check: int > 0) would no longer
// have an acceptable default when changing its check to, say, negative int (check: int < 0).
// 2. By overriding the output and removing previous output types, it's possible the default no longer has a valid type (it must be a valid output).
overrides.default = ()
}
let new-default = overrides.at("default", default: typeinfo.default)
let new-output = overrides.at("output", default: typeinfo.output)
let new-input = overrides.at("input", default: typeinfo.input)
let new-cast = overrides.at("cast", default: typeinfo.cast)
let new-check = overrides.at("check", default: typeinfo.check)
assert(
new-default == ()
or "any" in new-output
or base.typeid(new-default.first()) in new-output,
message: "elembic: types.wrap: new default (currently " + repr(if new-default == () { none } else { new-default.first() }) + ") must have a type within possible 'output' types of the new type (currently " + if new-output == () { "empty" } else { new-output.map(t => if type(t) == dictionary { t.name } else { str(t) }).join(", ", last: " or ") } + "), since it is itself an output\n hint: you can either change the default, or update possible output types with 'output: (new, list)' to indicate which native or custom types your wrapped type might end up as after casts (if there are casts)."
)
if new-check == none and new-cast == none and "any" not in new-output and (
"any" in new-input or new-input.any(inp => inp not in new-output)
) {
assert(false, message: "elembic: types.wrap: new type has no casting or checking, but not all of its input types are valid output types. Ensure 'output: (...)' and 'input: (...)' are identical to fix this.")
}
if new-cast == none and "any" not in new-input and new-output.any(out => out == "any" or out not in new-input) {
assert(false, message: "elembic: types.wrap: new type has no casting, but list of valid output types includes invalid input types. Please ensure 'output' is a subset of 'input' in this case, or add a cast.")
}
if (
(
"check" in overrides and new-check != typeinfo.check
or "output" in overrides and new-output != typeinfo.output
)
and "fold" not in overrides and typeinfo.fold != none
) {
// Folding might not be valid anymore:
// 1. By overriding the check, it's possible a fold that, say, adds two numbers, would no longer be valid
// if, for example, the new check ensures each number is smaller than 59 (you might add up to that).
// In addition, the fold might now receive parameters that would fail the new check while being cast.
// 2. By overriding the output:
// a. and removing old output, it's possible the fold produces invalid output.
// b. and adding new output, it's possible the fold receives parameters of an unexpected type.
assert(false, message: "elembic: types.wrap: new type has overridden check and/or output types, but not 'fold', usually a function (outer output, inner output) => folded output\n hint: it's possible the previous fold function could now produce a value that would never have passed the check or been a valid output type if kept, e.g. joining two single-element arrays would generate a two-element array which may violate a check that only allows casting single-element arrays into the new type\n hint: either explicitly remove folding with 'fold: none', keep the previous fold function with 'fold: prev => prev' if you're sure it still works properly with the new check or list of output types, or override it with 'fold: prev => (outer, inner) => folded'")
}
base.wrap(typeinfo, overrides)
}
// Specifies that any from a given selection of types is accepted.
#let union(..args) = {
let types = args.pos()
assert(types != (), message: "elembic: types.union: please specify at least one type")
let typeinfos = types.map(type_ => {
let (res, typeinfo-or-err) = validate(type_)
assert(res, message: if not res { "elembic: types.union: " + typeinfo-or-err } else { "" })
typeinfo-or-err
})
base.union(typeinfos)
}
// An optional type (can be 'none').
#let option(type_) = union(type(none), type_)
// A type which can be 'auto'.
#let smart(type_) = union(type(auto), type_)
#let array_(type_) = {
let (res, param) = validate(type_)
if not res {
assert(false, message: "elembic: types.array: " + param)
}
base.array_(
native.array_,
param,
error: if param.check == none {
a => {
let (count, message) = a.enumerate().fold((0, ""), ((count, message), (i, element)) => {
if "any" not in param.input and base.typeid(element) not in param.input {
(count + 1, message + "\n hint: at position " + str(i) + ": " + generate-cast-error(element, param))
} else {
(count, message)
}
})
let n-elements = if count == 1 { "an element" } else { str(count) + " elements" }
n-elements + " in an array of " + param.name + " did not typecheck" + message
}
} else {
a => {
let (count, message) = a.enumerate().fold((0, ""), ((count, message), (i, element)) => {
if "any" not in param.input and base.typeid(element) not in param.input or not (param.check)(element) {
(count + 1, message + "\n hint: at position " + str(i) + ": " + generate-cast-error(element, param))
} else {
(count, message)
}
})
let n-elements = if count == 1 { "an element" } else { str(count) + " elements" }
n-elements + " in an array of " + param.name + " did not typecheck" + message
}
}
)
}
#let dict_(type_) = {
let (res, param) = validate(type_)
if not res {
assert(false, message: "elembic: types.array: " + param)
}
base.dict_(
native.dict_,
param,
error: if param.check == none {
d => {
let (count, message) = d.pairs().fold((0, ""), ((count, message), (key, value)) => {
if "any" not in param.input and base.typeid(value) not in param.input {
(count + 1, message + "\n hint: at key " + repr(key) + ": " + generate-cast-error(value, param))
} else {
(count, message)
}
})
let n-elements = if count == 1 { "a value" } else { str(count) + " values" }
n-elements + " in a dictionary of " + param.name + " did not typecheck" + message
}
} else {
d => {
let (count, message) = d.pairs().fold((0, ""), ((count, message), (key, value)) => {
if "any" not in param.input and base.typeid(value) not in param.input or not (param.check)(value) {
(count + 1, message + "\n hint: at key " + repr(key) + ": " + generate-cast-error(value, param))
} else {
(count, message)
}
})
let n-elements = if count == 1 { "a value" } else { str(count) + " values" }
n-elements + " in a dictionary of " + param.name + " did not typecheck" + message
}
}
)
}
// Native paint type. Can be used for fills, strokes and so on.
#let paint = union(color, gradient, native.tiling_)
// Force the type to only accept its outputs (disallow casting).
// Folding is kept if possible.
#let exact(type_) = {
let (res, type_) = validate(type_)
if not res {
assert(false, message: "elembic: types.exact: " + type_)
}
let key = if type(type_) == dictionary and "type-kind" in type_ { type_.type-kind } else { none }
if key == "union" {
// exact(union(A, B)) === union(exact(A), exact(B))
union(..type_.data.map(exact))
} else if type(type_) == type or key == "native" {
// exact(float) => can only pass float, not int
// exact(stroke) => can only pass stroke, not length, gradient, dict, etc.
let native-type = type_.data
(
..native.generic-typeinfo(native-type),
default: if type_.default != () and type(type_.default.first()) == native-type { type_.default } else { () },
// Fold is an output => output function. The new output will be just (native-type,),
// so if fold previously accepted that native type, it will still accept it, so it
// can be kept.
fold: if native-type in type_.output { type_.fold } else { none },
)
} else if key == "literal" {
// exact(literal) => literal with base type modified to exact(base type)
assert(type(type_.data.value) not in (dictionary, array), message: "elembic: types.exact: exact literal types for custom types, dictionaries and arrays are not supported\n hint: consider customizing the check function to recursively check fields if the performance is acceptable")
base.literal(type_.data.value, exact(type_.data.typeinfo))
} else if key == "any" or key == "never" {
// exact(any) => any (same)
// exact(never) => never (same)
type_
} else if key == "collection" {
if "base" in type_.data and "parameters" in type_.data {
let base-kind = type_.data.base.at("type-kind", default: none)
if base-kind == "native" and type_.data.base.data == array {
array_(..type_.data.parameters.map(exact))
} else if base-kind == "native" and type_.data.base.data == dictionary {
dict_(..type_.data.parameters.map(exact))
} else {
assert(false, message: "elembic: types.exact: unknown collection with type kind '" + base-kind + "'" + if base-kind == "native" { ", base native type '" + type_.data.base.name + "'" } else { "" })
}
} else {
assert(false, message: "elembic: types.exact: invalid collection given")
}
} else if key == "custom" {
if type_.data.pre-casts == none {
type_
} else {
type_.data.pre-casts
}
} else {
assert(false, message: "elembic: types.exact: unsupported type kind " + key + ", supported kinds include native types, literals, custom types, arrays, dicts, 'any' and 'never'")
}
}

View File

@@ -0,0 +1,15 @@
[package]
name = "elembic"
version = "1.1.1"
compiler = "0.11.0"
homepage = "https://pgbiel.github.io/elembic"
repository = "https://github.com/PgBiel/elembic"
entrypoint = "src/lib.typ"
authors = ["PgBiel <https://github.com/PgBiel>"]
categories = ["scripting", "utility"]
license = "MIT OR Apache-2.0"
description = "Framework for custom elements and types in Typst"
keywords = ["element", "type", "validation", "styling"]
[tool.typst-test]
tests = "test/unit"

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Mc-Zen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,181 @@
![social-card](https://github.com/user-attachments/assets/d1d9eab9-deb8-4cd2-9dd5-78c26418ca98)
[![Typst Package](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Flilaq-project%2Flilaq%2Fv0.5.0%2Ftypst.toml&query=%24.package.version&prefix=v&logo=typst&label=package&color=239DAD)](https://typst.app/universe/package/lilaq)
[![Test Status](https://github.com/lilaq-project/lilaq/actions/workflows/run_tests.yml/badge.svg)](https://github.com/lilaq-project/lilaq/actions/workflows/run_tests.yml)
[![MIT License](https://img.shields.io/badge/license-MIT-blue)](https://github.com/lilaq-project/lilaq/blob/main/LICENSE)
[![Static Badge](https://img.shields.io/badge/documentation-736ad9)](https://lilaq.org/)
_Lilaq is a powerful plotting library for [Typst](https://typst.app/)._
----
You can install the package by cloning the repository and importing Lilaq with
```typ
#import "@preview/lilaq:0.5.0" as lq
```
The documentation lives at https://lilaq.org.
## Demos
Clicking on any demo image below will bring you to the respective page of the documentation that shows how to create this plot.
<table>
<tr>
<th>Line plot</th>
<th>Scatter</th>
<th>Bar chart</th>
</tr>
<tr>
<td>
[![simple plot demo](https://github.com/user-attachments/assets/c71886e0-e0a9-499e-848f-18580b1da523)](https://lilaq.org/docs/quickstart#the-first-plot)
</td>
<td>
[![scatter](https://github.com/user-attachments/assets/a1cf3019-b306-44a0-b28f-a2bb6fa522eb)](https://lilaq.org/docs/reference/scatter)
</td>
<td>
[![bar chart](https://github.com/user-attachments/assets/7822cc4f-6f12-4622-9f38-18ca7dd2a4fa)](https://lilaq.org/docs/reference/bar)
</td>
</tr>
<tr>
<th>Boxplot</th>
<th>Quiver</th>
<th>Styled marks</th>
</tr>
<tr>
<td>
[![boxplot](https://github.com/user-attachments/assets/09b1251a-46b3-488f-aab8-451d950c044c)](https://lilaq.org/docs/reference/boxplot)
</td>
<td>
[![quiver](https://github.com/user-attachments/assets/98f10346-2686-4c0c-8955-8a42465e65aa)](https://lilaq.org/docs/reference/quiver)
</td>
<td>
[![marks](https://github.com/user-attachments/assets/26e9e478-1599-4ded-9e6d-7fe0193ae6b9)](https://lilaq.org/docs/examples/styled-marks)
</td>
</tr>
<tr>
<th>Errorbars</th>
<th>Plot smoothing</th>
<th>Stem plot</th>
</tr>
<tr>
<td>
[![errorbars](https://github.com/user-attachments/assets/fdb054ad-e946-4ace-8a43-334676afebff)](https://lilaq.org/docs/reference/errorbar)
</td>
<td>
[![plot smoothing](https://github.com/user-attachments/assets/912bfbda-b091-4074-8601-3c173a8168da)](https://lilaq.org/docs/reference/plot#smooth)
</td>
<td>
[![stem plot](https://github.com/user-attachments/assets/070fb488-ff8b-401b-aba3-31f2ea02f996)](https://lilaq.org/docs/reference/stem)
</td>
</tr>
<tr>
<th>Colormesh</th>
<th>Contour</th>
<th>Color bar</th>
</tr>
<tr>
<td>
[![colormesh](https://github.com/user-attachments/assets/db59b76b-7eda-4045-9faf-e74fa9e92043)](https://lilaq.org/docs/reference/colormesh)
</td>
<td>
[![contour](https://github.com/user-attachments/assets/b60d8bac-faf6-4465-bd78-0687f3912108)](https://lilaq.org/docs/reference/contour)
</td>
<td>
[![color bar](https://github.com/user-attachments/assets/babf0319-e4e7-4c0d-90d4-ca80739773f6)](https://lilaq.org/docs/reference/colorbar)
</td>
</tr>
</table>
<table>
<tr>
<th>Spectrum with secondary axis</th>
<th>Climograph with twin axis</th>
</tr>
<tr>
<td>
[![spectrum plot](https://github.com/user-attachments/assets/2fe1b3e3-14b3-43ba-b117-e20151203a9c)](https://lilaq.org/docs/examples/dual-axis)
</td>
<td>
[![climograph](https://github.com/user-attachments/assets/4151bca1-67f5-41e3-aef3-b4d2e4c07eb9)](https://lilaq.org/docs/examples/climograph)
</td>
</tr>
<tr>
<th>Bar plot with numbers</th>
<th>Multiple twin axes</th>
</tr>
<tr>
<td>
[![bars with numbers](https://github.com/user-attachments/assets/c7e0edda-0b16-472b-83e3-1d639fc9c2b1)](https://lilaq.org/docs/examples/bar-plot-with-numbers)
</td>
<td>
[![twin axes](https://github.com/user-attachments/assets/b2706289-24a8-4e6d-bde0-119f13552855)](https://lilaq.org/docs/tutorials/axis#independent-axes-twin-axes)
</td>
</tr>
</table>
<table>
<tr>
<th>Plot within a plot</th>
<th>Koch snowflake fractal</th>
</tr>
<tr>
<td>
[![weierstrass function](https://github.com/user-attachments/assets/0181795f-9b5d-4552-9be3-c85ddcdba83a)](https://lilaq.org/docs/examples/plot-within-a-plot)
</td>
<td>
[![koch snowflake fractal](https://github.com/user-attachments/assets/14e5a26e-fd13-41ff-be73-817730e77dbf)](https://lilaq.org/docs/examples/koch-snowflake)
</td>
</tr>
</table>

View File

@@ -0,0 +1,79 @@
#import "linear-system.typ": thomas-algorithm
/// Solves for a system of linear equations $A dot arrow(x) = arrow(b)$
/// for the control points of the Bézier spline in either $x$ or $y$.
///
/// Returns an array of all control points (including start and end points) in $x$ or $y$.
///
/// -> array
#let solve-control-points-1d(
/// The matrix $A$ of the system of linear equations.
/// -> array
A,
/// The vector $b$ of the system of linear equations.
/// -> array
b,
/// The dimension $n$ of the system of linear eqautions.
/// -> int
n,
) = {
let s = range(2, n).map(i => (
2 * (2 * b.at(i - 1) + b.at(i))
))
s.insert(0, b.at(0) + 2 * b.at(1))
s.push(8 * b.at(n - 1) + b.at(n))
// first control points
let c1 = thomas-algorithm(A, s)
// second control points
let c2 = range(0, n - 1).map(i => 2 * b.at(i + 1) - c1.at(i + 1))
c2.push(0.5 * (c1.at(n - 1) + b.at(n)))
let points = c1.zip(c2, b.slice(1)).flatten()
points.insert(0, b.at(0))
points
}
/// Calculates the control points (including start and end points) for a Bézier spline
/// which interpolates the given data set.
/// For the boundary conditions a curvature of 0 was chosen for the start and end points.
///
/// Math from https://omaraflak.medium.com/b%C3%A9zier-interpolation-8033e9a262c2
///
/// Returns an array of points (2D arrays).
///
/// -> array
#let bezier-splines(
/// $x$ coordinates of the given points.
/// -> array
x,
/// $y$ coordinates of the given points.
/// -> array
y,
) = {
let n = y.len() - 1
if n < 2 {
panic("at least 3 points are required for calculating a Bézier spline")
}
let A = ((0,) * (n),) * (n)
for i in range(1, n - 1) {
A.at(i).at(i) = 4
A.at(i + 1).at(i) = 1
A.at(i).at(i + 1) = 1
}
A.at(0).at(0) = 2
A.at(1).at(0) = 1
A.at(0).at(1) = 1
A.at(n - 1).at(n - 1) = 7
A.at(n - 1).at(n - 2) = 2
let points-x = solve-control-points-1d(A, x, n)
let points-y = solve-control-points-1d(A, y, n)
let points = points-x.zip(points-y)
points
}

View File

@@ -0,0 +1,44 @@
#import "../math.typ": percentile
#import "../assertations.typ"
#let boxplot-statistics(x, whiskers: 1.5) = {
let stat = if type(x) == array {
import "@preview/komet:0.1.0"
return komet.boxplot(x, whisker-pos: whiskers)
} else if type(x) == dictionary {
assertations.assert-dict-keys(
x,
mandatory: ("median", "q1", "q3", "whisker-low", "whisker-high"),
optional: ("outliers", "mean"),
missing-message: key => "Boxplot data needs to specify \"" + key + "\"",
unexpected-message: (key, possible-keys) => "Boxplot data contains unexpected key \"" + key + "\" (expected " + possible-keys + ")"
)
if not "outliers" in x {
x.outliers = ()
}
x
} else {
assert(false, message: "Boxplot data either needs to be an array of values or a dictionary specifying boxplot statistics")
}
let iqr = stat.q3 - stat.q1
if type(x) == array {
let wlo = x.filter(x => x >= stat.q1 - iqr * whiskers)
let whi = x.filter(x => x <= stat.q3 + iqr * whiskers)
stat.whisker-low = calc.min(stat.q1, wlo.at(0, default: stat.q1))
stat.whisker-high = calc.max(stat.q3, whi.at(-1, default: stat.q3))
stat.outliers = x.filter(x => x < stat.whisker-low or x > stat.whisker-high)
}
stat.inter-quartile-range = iqr
stat
}

View File

@@ -0,0 +1,818 @@
#import "../math.typ": vec
/// Computes the orientation of a polygon, returns `left` if the polygon is
/// lefthanded (counter-clockwise) and `right` if it is righthanded (clockwise).
#let compute-polygon-orientation(..arr) = {
arr = arr.pos()
let r = arr.zip(arr.slice(1))
.map(((A, B)) => (A.at(0) - B.at(0)) * (A.at(1) + B.at(1)))
let s = r.sum(default: 0)
if s < -0.0000000000001 { return right }
else if s > 0.000000000001 { return left }
}
// #assert.eq(compute-polygon-orientation( (5, 5),
// (5, 5),
// (1.6666666666666665, 5),
// (1.666666666666667, 5),
// (5, 5),), none)
#assert.eq(compute-polygon-orientation((0,0), (0,1), (1,1), (1,0)), right)
#assert.eq(compute-polygon-orientation((0,0), (1,0), (1,1), (0,1)), left)
#assert.eq(compute-polygon-orientation((0,0), (1,0)), none)
#assert.eq(compute-polygon-orientation((0,0), (0,1)), none)
/// Retrieves the center coordinate of a hyperbolic paraboloid (HP) surface
/// given by the parameter values u=v=0.5.
/// p1 ·-----· p2
/// | · ←|
/// p4 ·-----· p3
#let get-hp-center-z(z1, z2, z3, z4) = {
return 0.25 * (z1 + z2 + z3 + z4)
}
/// Given two points $p_1$, $p_2$ in 3D space, find whether the line between
/// theses points intersects a plane $z=z_0$ and at which coordinates $(x,y)$.
/// Returns `none` if the segment does not intersect the plane between $p_1$
/// and $p_2$.
#let intersect-z-plane(p1, p2, z0) = {
let (dx, dy, dz) = vec.subtract(p2, p1)
if dz == 0 /*and z0 != p1.at(2)*/ { return none }
let ratio = (z0 - p1.at(2)) / dz
if ratio < 0 or ratio > 1 { return none }
return (p1.at(0) + ratio*dx, p1.at(1) + ratio*dy)
}
#assert.eq(intersect-z-plane((0,0,0), (2,0,1), .2), (.4, 0))
#assert.eq(intersect-z-plane((0,0,0), (2,0,1), 1), (2, 0))
#assert.eq(intersect-z-plane((0,0,0), (2,0,1), 1.1), none)
#assert.eq(intersect-z-plane((0,0,0), (2,0,1), -.01), none)
/// Given a list of (oriented) segments as pairs of points, returns
/// a list of polygons constructed from connected components in the set of
/// segments. The orientation plays a role.
#let group-segments(segments) = {
let cycles = ()
while segments.len() > 0 {
let segment = segments.pop()
let k = segments
let (p1, p2) = segment
let cycle = (p1, p2)
let start = segment
while true {
let pos = segments.position(x => x.first() == p2)
if pos != none {
start = segments.remove(pos)
p2 = start.last()
cycle.push(p2)
} else {
break
}
}
cycle = cycle.rev()
p1 = cycle.last()
while true {
let pos = segments.position(x => x.last() == p1)
if pos != none {
p1 = segments.remove(pos).first()
cycle.push(p1)
} else {
break
}
}
cycles.push(cycle)
}
return cycles
}
#let close-path-at-boundaries(path, boundaries: (xmin: 0, xmax: 1, ymin: 0, ymax: 1)) = {
let corners = (
(boundaries.xmax, boundaries.ymax),
(boundaries.xmax, boundaries.ymin),
(boundaries.xmin, boundaries.ymin),
(boundaries.xmin, boundaries.ymax)
)
if path.first() == path.last() { return path.rev() }
let get-case(point) = {
let case = 0
}
let get-edge(point) = {
if point.at(1) == boundaries.ymax { return 0 } // top
if point.at(1) == boundaries.ymin { return 2 } // bottom
if point.at(0) == boundaries.xmax { return 1 } // right
if point.at(0) == boundaries.xmin { return 3 } // left
let k = point
return none
}
let edge1 = get-edge(path.last())
let edge2 = get-edge(path.first())
if none in (edge1, edge2) { return none }
let edges = (edge1, edge2)
let rrange(a, b) = {
if a < b { a+= 4}
let r = calc.rem(1 - 3, 4)
if a <= b { range(a, b) }
else {
return range(b, a).rev()
}
}
let f = rrange(edge1, edge2)
if calc.rem-euclid(edge1 - edge2, 4) > 2 {
f = rrange(edge2, edge1).rev()
} else {
f = rrange(edge1, edge2)
}
for i in f {
path.push(corners.at(calc.rem(i, 4)))
}
path.push(path.first())
return path.rev()
}
#let mod-distance(a, b, div) = {
let diff = b - a
calc.abs(calc.rem(diff, div))
}
#assert.eq(mod-distance(0, 1, 4), 1)
#assert.eq(mod-distance(1, 0, 4), 1)
#assert.eq(mod-distance(1, 2, 4), 1)
#assert.eq(mod-distance(0, 2, 4), 2)
// #assert.eq(mod-distance(3, 0, 4), 1)
#assert.eq(mod-distance(2, 2, 4), 0)
#assert.eq(mod-distance(0, 2, 4), 2)
#assert.eq(mod-distance(2, 0, 4), 2)
#assert.eq(mod-distance(0, 5, 4), 1)
#let close-path-at-boundaries(path, boundaries: (xmin: 0, xmax: 1, ymin: 0, ymax: 1)) = {
let corners = (
(boundaries.xmax, boundaries.ymax),
(boundaries.xmax, boundaries.ymin),
(boundaries.xmin, boundaries.ymin),
(boundaries.xmin, boundaries.ymax)
)
if path.len() == 0 or path.first() == path.last() { return path.rev() }
let get-edge(point) = {
if point.at(1) == boundaries.ymax { return 0 } // top
if point.at(1) == boundaries.ymin { return 2 } // bottom
if point.at(0) == boundaries.xmax { return 1 } // right
if point.at(0) == boundaries.xmin { return 3 } // left
return none
}
let edge1 = get-edge(path.last())
let edge2 = get-edge(path.first())
if none in (edge1, edge2) { return none }
if edge1 == edge2 {
path.push(path.first())
return path.rev()
} else if calc.rem(edge1 + 2, 4) == edge2 { // opposite edges
if edge1 in (0,2) { // top/bottom
let mid-x = 0.5 * (boundaries.xmax - boundaries.xmin)
// if path.last().at(0) < mid-x
let vertices = (corners.at(0), corners.at(1))
if edge1 == 2 { vertices = vertices.rev() }
path += vertices
} else { // left/right
let vertices = (corners.at(3), corners.at(0))
// let vertices = (corners.at(2), corners.at(1))
if edge1 == 1 { vertices = vertices.rev() }
path += vertices
if path.last().last() < -3 {
let k = path
}
}
} else {
if calc.rem(edge1 + 1, 4) == edge2 {
path.push(corners.at(edge1))
} else if calc.rem(edge2 + 1, 4) == edge1 {
path.push(corners.at(edge2))
} else { assert(false) }
}
path.push(path.first())
return path.rev()
let edges = (edge1, edge2)
let rrange(a, b) = {
if a < b { a+= 4}
let r = calc.rem(1 - 3, 4)
if a <= b { range(a, b) }
else {
return range(b, a).rev()
}
}
let f = rrange(edge1, edge2)
if calc.rem-euclid(edge1 - edge2, 4) > 2 {
f = rrange(edge2, edge1).rev()
} else {
f = rrange(edge1, edge2)
}
for i in f {
path.push(corners.at(calc.rem(i, 4)))
}
path.push(path.first())
return path.rev()
}
#let test-close-boundaries(curve, result) = {
let test-boundaries = (xmin: 0, xmax: 1, ymin: 0, ymax: 1)
let closed-curve = close-path-at-boundaries(curve, boundaries: test-boundaries)
if result != none {
assert.eq(closed-curve, result.rev())
}
box(width: 1cm, height: 1cm, fill: luma(90%), {
let transform(p) = (p.at(0)*1cm, 1cm - p.at(1)*1cm)
let vertices = closed-curve.map(transform)
place(std.curve(
std.curve.move(vertices.first()),
..vertices.slice(1).map(std.curve.line),
))
})
}
// #test-close-boundaries((), ())
#test-close-boundaries(((.5,0), (1,.5)),
((.5, 0), (1, .5), (1, 0), (.5, 0))
)
#test-close-boundaries(((1,.5), (.5,0)),
((1, .5), (.5, 0), (1, 0), (1, .5))
)
#test-close-boundaries(((1,.5), (.5,1)),
((1, .5), (.5, 1), (1, 1), (1, .5))
)
#test-close-boundaries(((.5,1), (1,.5)),
((.5, 1), (1, .5), (1, 1), (.5, 1))
)
#test-close-boundaries(((0,.5), (.5,1)),
((0, .5), (.5, 1), (0, 1), (0, .5))
)
#test-close-boundaries(((.5,1), (0,.5)),
((.5, 1), (0, .5), (0, 1), (.5, 1))
)
#test-close-boundaries(((0,.5), (.5,0)),
((0, .5), (.5, 0), (0, 0), (0, .5))
)
#test-close-boundaries(((.5,0), (0,.5)),
((.5, 0), (0, .5), (0, 0), (.5, 0))
)
#test-close-boundaries(((.2,0), (.5, .5), (.8, 0)),
((.2,0), (.5, .5), (.8, 0), (.2, 0))
)
#test-close-boundaries(((.2, 1), (.5, .5), (.8, 1)),
((.2, 1), (.5, .5), (.8, 1), (.2, 1))
)
#test-close-boundaries(((0, .2), (.5, .5), (0, .8)),
((0, .2), (.5, .5), (0, .8), (0, .2))
)
#test-close-boundaries(((1, .2), (.5, .5), (1, .8)),
((1, .2), (.5, .5), (1, .8), (1, .2))
)
#test-close-boundaries(((1, 0), (0, 1)),
none
)
#test-close-boundaries(((0, 1), (1, 0)),
none
)
#test-close-boundaries(((1, 1), (0, 0)),
none
)
#test-close-boundaries(((0, 0), (1, 1)),
none
)
#test-close-boundaries(((1, 0), (0, .5)),
none
)
#test-close-boundaries(((1, 0), (.5, 1)),
none
)
#test-close-boundaries(((0, .2), (1, .3)),
none
)
#test-close-boundaries(((0, .7), (1, .8)),
none
)
#test-close-boundaries(((1, 0), (1, 1)), none)
#test-close-boundaries(((1, 1), (1, 0)), none)
#let generate-contour(x, y, z, level, z-range: 1) = {
let get-z(i, j) = { z.at(i).at(j) }
let get-p(i, j) = { (x.at(i), y.at(j), z.at(i).at(j)) }
let blocks = ()
for i in range(x.len() - 1) {
for j in range(y.len() - 1) {
let zs = (get-z(i, j), get-z(i+1, j), get-z(i, j+1), get-z(i+1,j+1))
if calc.min(..zs) <= level and calc.max(..zs) >= level {
blocks.push((i, j))
}
}
}
let segments = ()
while blocks.len() > 0 {
let (i, j) = blocks.pop()
/*
0: → (0,0) -> (1,0)
1: ↓ (1,0) -> (1,1)
2: ← (1,1) -> (0,1)
3: ↑ (0,1) -> (0,0)
*/
let qqqs = ((0,0), (1,0), (1,1), (0,1), (0,0))
let intersections = ()
for edge in range(4) {
let (Ax, Ay) = qqqs.at(edge)
let (Bx, By) = qqqs.at(edge + 1)
let p1 = get-p(Ax + i, Ay + j)
let p2 = get-p(Bx + i, By + j)
if edge < 2 { (p1, p2) = (p2, p1) }
let interpolation = intersect-z-plane(p1, p2, level)
if interpolation == p2.slice(0,2) and edge > 2 {
// edge += 1
}
if interpolation != none {
intersections.push((edge, interpolation))
}
}
intersections = intersections.dedup(key: x => x.at(1))
let sort-intersection-tuple(i1, i2) = {
let corner = qqqs.at(i2.at(0))
if corner == i2.at(1) {
// (i1, i2) = (i2, i1)
}
if get-z(..vec-add(corner, (i,j))) < level {
(i1, i2) = (i2, i1)
}
return (i1.at(1), i2.at(1))
}
// assert(intersections.len() in (2,4))
if intersections.len() == 2 {
segments.push(sort-intersection-tuple(..intersections))
} else if intersections.len() == 4 {
let z0 = get-hp-center-z(get-z(i, j), get-z(i+1, j), get-z(i, j+1), get-z(i+1,j+1))
let (a,b,c,d) = intersections
if calc.abs(z0 - level) < 1e-6* z-range {
let center = (0.5*(x.at(i) + x.at(i+1)), 0.5*(y.at(j) + y.at(j+1)))
let (c1, c2) = (center, center)
c1.at(0) += 1e-30
c2.at(0) -= 1e-30
c1.at(0) += .2
c2.at(0) -= .2
if get-z(i, j) > level{ (c1, c2) = (c2, c1)}
let seg1 = sort-intersection-tuple(a, d)
let seg2 = sort-intersection-tuple(b, c)
segments.push((seg1.at(0), c1))
segments.push((seg2.at(0), c2))
if get-z(i, j) > level {
(c1, c2) = (c2, c1)
}
segments.push((c2, seg1.at(1)))
segments.push((c1, seg2.at(1)))
// segments.push((a,b,c,d))
} else if z0 > level {
if get-z(i, j) > level {
(b, d) = (d, b)
}
segments.push(sort-intersection-tuple(a,d))
segments.push(sort-intersection-tuple(c,b))
// segments.push((a,d,b,c))
} else if level > z0 {
if get-z(i, j) > level {
(d, b) = (b, d)
}
segments.push(sort-intersection-tuple(a,b))
segments.push(sort-intersection-tuple(c,d))
// segments.push((a,c,b,d))
}
} else {
// segments.push(sort-intersection-tuple(..intersections.slice(0,2)))
// let o = (i,j)
// assert(false)
}
}
let paths = group-segments(segments)
return paths
}
#let lshift(a) = 2*a
#let bw-or(a,b) = {
let c = 0;
let n = 1;
while ((a > 0) or (b > 0)) {
if ((calc.rem(a, 2) == 1) or (calc.rem(b, 2) == 1)) {
c += n;
}
a = calc.quo(a, 2);
b = calc.quo(b, 2);
n = n * 2;
}
return c;
}
#assert.eq(bw-or(0b00, 0b11), 0b11)
#assert.eq(bw-or(0b010001, 0b110100), 0b110101)
#let compute-case(corners-z, level) = {
let case = 0
for z in corners-z {
case = bw-or(lshift(case), if z > level {1} else {0})
}
if case == 0 {
if corners-z.filter(x => x == level).len() == 2 {
for z in corners-z {
case = bw-or(lshift(case), if z >= level {1} else {0})
}
}
}
return case
}
#assert.eq(compute-case((-1,-1,-1,-1), 0), 0)
#assert.eq(compute-case((1,1,1,1), 0), 15)
#assert.eq(compute-case((-1,-1,-1,1), 0), 1)
#assert.eq(compute-case((-1,-1,1,-1), 0), 2)
#assert.eq(compute-case((-1,-1,1,1), 0), 3)
#assert.eq(compute-case((-1,1,-1,-1), 0), 4)
#assert.eq(compute-case((-1,-1,-1,1), 0), 1)
#assert.eq(compute-case((0,0,-1,-1), 0), 12)
#let generate-contour(x, y, z, level, z-range: 1) = {
let z-eps = 1e-6 * z-range
let x-eps = 1e-17 * (x.last() - x.first())
let get-z(i, j) = { z.at(j).at(i) }
let get-p(i, j) = { (x.at(i), y.at(j), z.at(j).at(i)) }
let blocks = ()
for i in range(x.len() - 1) {
for j in range(y.len() - 1) {
let zs = (get-z(i, j), get-z(i+1, j), get-z(i, j+1), get-z(i+1,j+1))
if calc.min(..zs) <= level and calc.max(..zs) >= level {
blocks.push((i, j))
}
}
}
let segments = ()
while blocks.len() > 0 {
let (i, j) = blocks.pop()
let corners = ((0,0), (1,0), (1,1), (0,1))
let case = compute-case(corners.map(corner => get-p(..vec.add((i, j), corner)).at(2)), level)
let qqqs = ((0,0), (1,0), (1,1), (0,1), (0,0))
let get-edge-intersection(edge) = {
let (Ax, Ay) = qqqs.at(edge)
let (Bx, By) = qqqs.at(edge + 1)
let p1 = get-p(Ax + i, Ay + j)
let p2 = get-p(Bx + i, By + j)
if edge < 2 { (p1, p2) = (p2, p1) }
let interpolation = intersect-z-plane(p1, p2, level)
interpolation
}
let get-line-segment(edge1, edge2) = ((edge2, edge1).map(get-edge-intersection),)
let z0 = get-hp-center-z(get-z(i, j), get-z(i+1, j), get-z(i, j+1), get-z(i+1,j+1))
let cases = (
() => (),
() => get-line-segment(3,2),
() => get-line-segment(2,1),
() => get-line-segment(3,1),
() => get-line-segment(1,0),
() => {
if calc.abs(z0 - level) < z-eps {
let (i0,i1,i2,i3) = (0,1,2,3).map(get-edge-intersection)
let center = (i0.at(0), i1.at(1))
let (c1, c2) = (center, center)
c1.at(0) += x-eps
c2.at(0) -= x-eps
((i0, c2), (c2, i3), (i2, c1), (c1, i1))
} else if z0 > level {
get-line-segment(3,0) + get-line-segment(1,2)
} else if z0 < level {
get-line-segment(1,0) + get-line-segment(3,2)
}
},
() => get-line-segment(2,0),
() => get-line-segment(3,0),
() => get-line-segment(0,3),
() => get-line-segment(0,2),
() => {
if calc.abs(z0 - level) < z-eps {
let (i0,i1,i2,i3) = (0,1,2,3).map(get-edge-intersection)
let center = (i0.at(0), i1.at(1))
let (c1, c2) = (center, center)
c1.at(0) -= x-eps
c2.at(0) += x-eps
((i1, c2), (c2, i0), (i3, c1), (c1, i2))
} else if z0 > level {
get-line-segment(0,1) + get-line-segment(2,3)
} else if z0 < level {
get-line-segment(0,3) + get-line-segment(2,1)
}
},
() => get-line-segment(0,1),
() => get-line-segment(1,3),
() => get-line-segment(1,2),
() => get-line-segment(2,3),
() => (),
)
let k = (i,j,case, (get-z(i, j), get-z(i+1, j), get-z(i, j+1), get-z(i+1,j+1)))
segments += cases.at(case)()
}
let paths = group-segments(segments.dedup())
return paths
}
#let generate-contours(x, y, z, levels, z-range: 1) = {
return levels.map(level => generate-contour(x, y, z, level, z-range: z-range))
}
#let gen-contour-1rect = generate-contour.with((0,1), (0,1))
#let test-contour-case(x: (0,1), y: (0,1), z, level, answer) = {
let z-flat = z.flatten()
let (min, max) = (calc.min(..z-flat), calc.max(..z-flat))
if min == max {min -=1 }
let contour = generate-contour(x, y, z, level)
assert.eq(contour, answer)
box(width: 2cm, height: 2cm, stroke: .5pt,
{
let sq = box.with(width: 1cm, height: 1cm)
let cmap = color.map.icefire
let cmap = (blue, yellow, red)
let grad = gradient.linear(..cmap)
let get-clr(z) = grad.sample((z - min)/(max - min)*100%)
for i in range(x.len()) {
for j in range(y.len()) {
place(
dx: i * 1cm, dy: 1cm - j*1cm,
sq(fill: get-clr(z.at(j).at(i)))
)
}
}
let transform(p) = (p.at(0)*2cm, 2cm - p.at(1)*2cm)
contour.map(contour-path => {
contour-path = contour-path.map(transform)
place(curve(
curve.move(contour-path.first()),
..contour-path.slice(1).map(curve.line),
stroke: get-clr(level).lighten(20%) + 2pt,
)) + place(
dx: (contour-path.first()).at(0),
dy: (contour-path.first()).at(1),
place(center + horizon, circle(fill: black, radius: 1pt))
)}).join()
}
)
}
#test-contour-case(
x: (-1,0,1),
y: (-5,5),
// ((-1,-1),(0,0),(1,1)),
((-1,0,1),(-1,0,1)),
0,
((((0, 5), (0, -5)), ))
),
#set page(height: auto, width: auto, margin: 1cm)
The dot indicates the start of a curve. A curve is to be oriented such that the _higher_ profile lies _left_ of it.
== No intersection
#set table(align: center)
#table(columns: 3,
[Right on], [all below], [all above],
test-contour-case(((0,0),(0,0)), 0, ()),
test-contour-case(((20,-20),(-20,10)), 50, ()),
test-contour-case(((20,-20),(-20,10)), -50, ()),
)
== Saddles
// HP saddle directly through center
#table(columns: 6,
[Right on], [slightly above], [slightly below],
[Right on], [slightly above], [slightly below],
test-contour-case(((-1,1),(1,-1)), 0,
(((1, .5), (.5,.5), (.5,0)), ((0,.5), (.5,.5), (.5, 1)))
),
test-contour-case(((-1,1),(1,-1)), 0.5,
((((0,.75), (.25, 1)), ((1,.25), (.75,0))))
),
test-contour-case(((-1,1),(1,-1)), -0.5,
((((1,.75), (.75,1)), ((0,.25), (.25, 0))))
),
test-contour-case(((1,-1),(-1,1)), 0,
(((.5,1), (.5,.5), (1, .5)), ((.5, 0),(.5,.5), (0,.5)))
),
test-contour-case(((1,-1),(-1,1)), 0.5,
((((.75,1), (1,.75)), ((.25,0), (0,.25))))
),
test-contour-case(((1,-1),(-1,1)), -0.5,
((((.25,1), (0,.75)), ((.75,0), (1,.25))))
),
[Offset y], [Offset x], [skew below], [], [], [],
test-contour-case(
((-3,3),(1,-1)),
0,
(((1, .75), (.5,.75), (.5,0)),
((0,.75), (.5,.75), (.5, 1)))
),
test-contour-case(
((-3,1),(3,-1)), 0,
(((1, .5), (.75,.5), (.75,0)), ((0,.5), (.75,.5), (.75, 1)))
),
test-contour-case(((-3,3),(3,-1)), 0,
(((1, .75), (.75,1)), ((0,.5), (.5, 0)))
),
)
== Cross to opposite side
#table(columns: 4,
[Left to right], [Right to left], [Top to bottom], [Bottom to top],
// o o
// ---→
// x x
test-contour-case(((1,0),(2, 6)), 1.5,
((((0,.5), (1, .25)), ))
),
// x x
// ←---
// o o
test-contour-case(((2,6),(1, 0)), 1.5,
((((1,.75), (0, .5)), ))
),
// x | o
// |
// x ↓ o
test-contour-case(((1,2),(0, 6)), 1.5,
((((.25,1), (.5, 0)), ))
),
// o ↑ x
// |
// o | x
test-contour-case(((2,0),(6,1)), 1.5,
((((.25,0), (.9,1)), ))
),
)
// let mesh_ = create-mesh(linspace(-5, 5, num: 3), linspace(-5, 5, num: 2), func)
== Corners
#table(columns: 4,
[Bottom to left], [Left to bottom],[Right to bottom],[Bottom to right],
// x x
// ←-|
// o | x (o above level)
test-contour-case(((6,2),(0,0)), 3,
((((.75,0), (0,.5)), ))
),
// o o
// --|
// x ↓ o (x below level)
test-contour-case(((-4,2),(0,0)), -1,
((((0,.75), (.5,0)), ))
),
// x x
// |--
// x ↓ o
test-contour-case(((-4,4),(-1,-1)), 0,
((((1,.8), (.5,0)), ))
),
// o o
// |-→
// o | x
test-contour-case(((4,-4),(1,1)), 0,
((((.5,0), (1,.8)), ))
),
[Top to right], [Right to top],[Left to top],[Top to left],
// x | o
// |-→
// x x
test-contour-case(((-4,-4),(-4,4)), 0,
((((.5,1), (1,.5)), ))
),
// o ↑ x
// |--
// o o
test-contour-case(((4,4),(4,-4)), 0,
((((1,.5), (.5,1)), ))
),
// o ↑ x
// --|
// x x
test-contour-case(((-4,-4),(4,-4)), 0,
((((0,.5), (.5,1)), ))
),
// x | o
// ←-|
// o o
test-contour-case(((4,4),(-4,4)), 0,
((((.5,1), (0,.5)), ))
),
)
== Edge cases
#table(columns: 4,
[Top below], [Top above], [Bottom below], [Bottom above],
// ·←--·
//
// o o
test-contour-case(((3,4),(0,0)), 0,
((((1,1), (0,1)), ))
),
// ·--→·
//
// x x
test-contour-case(((-3,-4),(0,0)), 0,
((((0,1), (1,1)), ))
),
// o o
//
// ·--→·
test-contour-case(((0,0),(3,4)), 0,
((((0,0), (1,0)), ))
),
// x x
//
// ·←--·
test-contour-case(((0,0),(-3,-4)), 0,
((((1,0), (0,0)), ))
),
[Left below], [Left above], [Top below], [Top above],
// · o
// ↓
// · o
test-contour-case(((0,3),(0,4)), 0,
((((0,1), (0,0)), ))
),
// · x
// ↑
// · x
test-contour-case(((0,-3),(0,-4)), 0,
((((0,0), (0,1)), ))
),
// o ·
// ↑
// o ·
test-contour-case(((3,0),(4,0)), 0,
((((1,0), (1,1)), ))
),
// x ·
// ↓
// x ·
test-contour-case(((-3,0),(-4,0)), 0,
((((1,1), (1,0)), ))
),
)

View File

@@ -0,0 +1,9 @@
#let gaussian-kde(x, bw-method) = {
if type(bw-method) in (int, float) {
bw-method = (x => bw-method)
}
let factor = bw-method()
let dim = x.len()
}

View File

@@ -0,0 +1,36 @@
/// Computes the value at a rational index into an array by performing linear
/// interpolation. If the index is an integer, the exact value at the index is
/// returned.
///
/// -> int | float
#let linear(
/// Data values to interpolate from.
/// -> array
values,
/// Rational index into the array.
/// -> int | float
index
) = {
let n = values.len()
assert(n > 0, message: "An array with at least one element is required for interpolation")
assert(0 <= index and index <= n - 1, message: "Index " + str(index) + " out of range for interpolation with array of length " + str(n))
if index < 0 { return values.first() }
if index >= n - 1 { return values.last() }
let lower = calc.floor(index)
let upper = lower + 1
let t = calc.fract(index)
values.at(lower) * (1 - t) + values.at(upper) * t
}
#assert.eq(linear((1,2,8), 0), 1)
#assert.eq(linear((1,2,8), 0.5), 1.5)
#assert.eq(linear((1,2,8), 1.5), 5)
#assert.eq(linear((1,2,8), 1), 2)
#assert.eq(linear((1,2,8), 2), 8)

View File

@@ -0,0 +1,59 @@
/// Solve a system of linear equations $A dot arrow(x) = arrow(b)$
/// where $A$ is a tridiagonal matrix.
/// See https://en.wikipedia.org/wiki/Tridiagonal_matrix_algorithm
/// for more information.
///
/// Returns the solutions $arrow(x) in RR^n$ of the system of linear equqations.
///
/// -> array
#let thomas-algorithm(
/// The matrix $A in RR^(n times n)$ of the system of linear equations.
/// The data format is an array of arrays, in row-major order.
///
/// -> array
A,
/// The vector $arrow(b) in RR^n$ of the system of linear equations.
///
/// -> array
b,
) = {
let n = b.len()
if n == 1 {
return (b.at(0) / A.at(0).at(0),)
}
let beta = (0,) * n
let gamma = (0,) * n
let y = (0,) * n
beta.at(0) = A.at(0).at(0)
gamma.at(0) = A.at(0).at(1) / beta.at(0)
y.at(0) = b.at(0) / beta.at(0)
for i in range(1, n) {
let d-i = A.at(i).at(i)
let e-i = A.at(i).at(i - 1)
beta.at(i) = d-i - e-i * gamma.at(i - 1)
if i < n - 1 {
let c-i = A.at(i).at(i + 1)
gamma.at(i) = c-i / beta.at(i)
}
// backward elimination
y.at(i) = (b.at(i) - e-i * y.at(i - 1)) / beta.at(i)
}
let x = (0,) * n
x.at(n - 1) = y.at(n - 1)
// forward elimination
for i in range(n - 2, -1, step: -1) {
x.at(i) = y.at(i) - gamma.at(i) * x.at(i + 1)
}
x
}

View File

@@ -0,0 +1,154 @@
/// Assert that two floats or arrays are equal up to a (configurable) epsilon.
#let approx(
/// First value.
a,
/// Second value.
b,
/// Absolute tolerance.
eps: 1e-20,
// Relative tolerance
rel-eps: 1e-09
) = {
if type(a) == array and type(b) == array {
assert(a.len() == b.len(), message: "Non-matching array lengths " + repr(a) + " / " + repr(b))
for (x, y) in a.zip(b) {
// if calc.abs(x - y) >= eps and calc.abs(1 - calc.abs(x / y)) >= eps {
if calc.abs(x - y) > calc.max(rel-eps * calc.max(calc.abs(x), calc.abs(y)), eps) {
assert(false, message: repr(x) + " was not approx equal to " + repr(y) )
assert(false, message: repr(a) + " was not approx equal to " + repr(b) )
}
}
} else {
assert(calc.abs(a - b) < eps, message: str(a) + " and " + str(b) + " are not equal up to " + str(eps))
}
}
#approx(1, 1)
#approx((5e45, 1e46, 1.4999999999999999e46, 2e46), (5e45, 1e46, 1.5e46, 2e46))
/// Assert that coordinate arrays passed to plots have the same length.
#let assert-matching-data-dimensions(
/// Array of $x$ coordinates.
x,
/// Array of $y$ coordinates.
y,
/// Other coordinate arrays (e.g., error bars) that should match the length of $x$ and $y$ coordinates. Only named arguments are accepted.
..args,
/// Function name. This can be used to improve the error message. Entries where the value is not an array are ignored. This is useful for cases like `xerr` that also take a single value.
fn-name: "",
) = {
let prefix = ""
if fn-name != "" { prefix = "`" + fn-name + "()`: " }
if y != none {
assert(
x.len() == y.len(),
message: prefix + "The dimensions for x (" + str(x.len()) + ") and y (" + str(y.len()) + ") don't match"
)
}
for (key, value) in args.named() {
if value == none { continue }
if type(value) != array { continue }
assert(
x.len() == value.len(),
message: prefix + "The dimensions for x (" + str(x.len()) + ") and " + key + " (" + str(value.len()) + ") don't match"
)
}
}
/// Assert that there are no additional named arguments in an argument sink.
#let assert-no-named(
/// Argument sink.
args,
/// Function name. This can be used to improve the error message.
fn: ""
) = {
if args.named().len() == 0 { return }
assert(false,
message: "Unexpected named argument \"" + args.named().keys().first() + "\"" + if fn == "" {""} else {
" in function " + fn + "()"
}
)
}
/// Assert that there are no additional positional arguments in an argument sink.
#let assert-no-positional(
/// Argument sink.
args
) = assert.eq(
args.pos().len(),
0,
message: "Unexpected positional arguments"
)
#let assert-dict-keys(
dict,
mandatory: (),
optional: (),
name: "",
message: auto,
missing-message: auto,
unexpected-message: auto
) = {
name += " "
for key in mandatory {
if key not in dict {
if message == auto {
message = name + "dictionary expects key `" + key + "`"
}
if type(missing-message) == function {
message = missing-message(key)
}
assert(
false,
message: message
)
}
let _ = dict.remove(key)
}
for key in dict.keys() {
if key not in optional {
if message == auto {
message = name + "dictionary found unexpected key `" + key + "` (expected " + (mandatory + optional).join(", ", last: ", or ") + ")"
}
if type(unexpected-message) == function {
message = unexpected-message(key, (mandatory + optional).join(", ", last: ", or "))
}
assert(
false,
message: message
)
}
}
}
// #assert-dict-keys((a: 12, b: "", c: "a"), mandatory: ("a", "b"), optional: ("c",))

View File

@@ -0,0 +1,190 @@
#let get-length(x, container-length) = {
if type(x) == length { return x }
if type(x) == ratio { return x * container-length}
if type(x) == relative { return x.length + x.ratio * container-length}
}
#assert.eq(get-length(3cm, 234cm), 3cm)
#assert.eq(get-length(50%, 224cm), 112cm)
#assert.eq(get-length(50% + 3cm, 224cm), 115cm)
#let update-bounds(former, bounds, width: 0cm, height: 0cm) = (
left: calc.min(former.left, get-length(bounds.left, width).to-absolute()),
top: calc.min(former.top, get-length(bounds.top, height).to-absolute()),
right: calc.max(former.right, get-length(bounds.right, width).to-absolute()),
bottom: calc.max(former.bottom, get-length(bounds.bottom, height).to-absolute()),
)
#context assert.eq(update-bounds(
(left: 0pt, right: 0pt, top: 0pt, bottom: 0pt),
(left: 0pt, right: 0pt, top: 0pt, bottom: 0pt),
), (left: 0pt, right: 0pt, top: 0pt, bottom: 0pt))
#context assert.eq(update-bounds(
(left: 0pt, right: 0pt, top: 0pt, bottom: 0pt),
(left: -10pt, right: 20pt, top: -20pt, bottom: 100pt),
), (left: -10pt, right: 20pt, top: -20pt, bottom: 100pt))
#context assert.eq(update-bounds(
(left: 0pt, right: 0pt, top: 0pt, bottom: 0pt),
(left: -3em, right: 20pt + 1em, top: -20pt, bottom: 2em + 1pt),
), (left: -33pt, right: 31pt, top: -20pt, bottom: 23pt))
#let create-bounds() = (left: 0pt, right: 0pt, top: 0pt, bottom: 0pt)
#let offset-bounds(bounds, offset) = (
left: bounds.left + offset.at(0),
top: bounds.top + offset.at(1),
right: bounds.right + offset.at(0),
bottom: bounds.bottom + offset.at(1),
)
#let place-with-bounds(
content,
dx: 0pt,
dy: 0pt,
pad: 0pt,
alignment: top + left,
content-alignment: auto,
wrap-in-box: false
) = {
if alignment.x == none { alignment = alignment.y + center }
else if alignment.y == none { alignment = alignment.x + horizon }
if content-alignment == auto { content-alignment = alignment.inv() }
else if content-alignment == "inside" { content-alignment = alignment }
let size = measure(content)
if pad != 0pt {
if type(pad) != dictionary {
pad = (x: pad, y: pad)
}
if content-alignment.y == bottom { pad.y *= -1 }
else if content-alignment.y == horizon { pad.y *= 0 }
dy += pad.y
if content-alignment.x == right { pad.x *= -1 }
else if content-alignment.x == center { pad.x *= 0 }
dx += pad.x
}
let (ddx, ddy) = (dx, dy)
if wrap-in-box {
content = box(..size, content)
}
let content = place(content-alignment, content)
if alignment.x == right { dx += 100% }
else if alignment.x == center { dx += 50% }
if alignment.y == bottom { dy += 100% }
else if alignment.y == horizon { dy += 50% }
if content-alignment.x == right { dx -= size.width }
else if content-alignment.x == center { dx -= 0.5 * size.width }
if content-alignment.y == bottom { dy -= size.height }
else if content-alignment.y == horizon { dy -= 0.5 * size.height }
let bounds = (
left: dx,
right: dx + size.width,
top: dy,
bottom: dy + size.height
)
(place(alignment, content, dx: ddx, dy: ddy), bounds)
}
#let place-and-show-bounds(content, alignment, ca: auto, pad: 0pt) = context {
let ca = ca
if ca == auto { ca = alignment.inv() }
let (content, bounds) = place-with-bounds(alignment: alignment, content, content-alignment: ca, pad: pad)
place(dx: bounds.left, dy: bounds.top, box(width: bounds.right - bounds.left, height: bounds.bottom - bounds.top))
content
}
#place-and-show-bounds([JOO], top , ca: right)
#rect(
width: 6cm, height: 4cm, inset: 0pt,
{
set box(fill: green.lighten(50%))
place-and-show-bounds([Top right], right + top)
place-and-show-bounds([Bottom right], right + bottom)
place-and-show-bounds([Top left], left + top)
place-and-show-bounds([Bottom left], left + bottom)
place-and-show-bounds([Top], top + center)
place-and-show-bounds([Bottom], bottom + center)
place-and-show-bounds([Middle], horizon + center)
place-and-show-bounds([Right], horizon + right)
place-and-show-bounds([Left], horizon + left)
set box(fill: purple.lighten(50%))
place-and-show-bounds([Top right], right + top, ca: right + top)
place-and-show-bounds([Bottom right], right + bottom, ca: right + bottom)
place-and-show-bounds([Top left], left + top, ca: left + top)
place-and-show-bounds([Bottom left], left + bottom, ca: left + bottom)
place-and-show-bounds([Top], top + center, ca: top + center)
place-and-show-bounds([Bottom], bottom + center, ca: bottom + center)
place-and-show-bounds([Middle], horizon + center, ca: horizon + center)
place-and-show-bounds([Right], horizon + right, ca: horizon + right)
place-and-show-bounds([Left], horizon + left, ca: horizon + left)
}
)
\
#rect(
width: 6cm, height: 4cm, inset: 0pt,
{
set box(fill: red.lighten(50%))
place-and-show-bounds([Top right], right + top, ca: center + horizon)
place-and-show-bounds([Bottom right], right + bottom, ca: center + horizon)
place-and-show-bounds([Top left], left + top, ca: center + horizon)
place-and-show-bounds([Bottom left], left + bottom, ca: center + horizon)
place-and-show-bounds([Top], top + center, ca: center + horizon)
place-and-show-bounds([Bottom], bottom + center, ca: center + horizon)
place-and-show-bounds([Middle], horizon + center, ca: center + horizon)
place-and-show-bounds([Right], horizon + right, ca: center + horizon)
place-and-show-bounds([Left], horizon + left, ca: center + horizon)
}
)
With auto padding
#rect(
width: 6cm, height: 4cm, inset: 0pt,
{
set box(fill: green.lighten(50%))
place-and-show-bounds = place-and-show-bounds.with(pad: 5pt)
place-and-show-bounds([Top right], right + top)
place-and-show-bounds([Bottom right], right + bottom)
place-and-show-bounds([Top left], left + top)
place-and-show-bounds([Bottom left], left + bottom)
place-and-show-bounds([Top], top + center)
place-and-show-bounds([Bottom], bottom + center)
place-and-show-bounds([Middle], horizon + center)
place-and-show-bounds([Right], horizon + right)
place-and-show-bounds([Left], horizon + left)
set box(fill: purple.lighten(50%))
place-and-show-bounds([Top right], right + top, ca: right + top)
place-and-show-bounds([Bottom right], right + bottom, ca: right + bottom)
place-and-show-bounds([Top left], left + top, ca: left + top)
place-and-show-bounds([Bottom left], left + bottom, ca: left + bottom)
place-and-show-bounds([Top], top + center, ca: top + center)
place-and-show-bounds([Bottom], bottom + center, ca: bottom + center)
place-and-show-bounds([Middle], horizon + center, ca: horizon + center)
place-and-show-bounds([Right], horizon + right, ca: horizon + right)
place-and-show-bounds([Left], horizon + left, ca: horizon + left)
place-and-show-bounds([JOO], top + right, ca: left)
}
)

View File

@@ -0,0 +1,51 @@
#import "math.typ": *
#import "model/diagram.typ": diagram
#import "model/axis.typ": axis, xaxis, yaxis
#import "model/tick.typ": tick, tick-label
#import "model/spine.typ": spine
#import "model/title.typ": title
#import "model/legend.typ": legend
#import "model/grid.typ": grid
#import "model/errorbar.typ": errorbar
#import "model/label.typ": label, xlabel, ylabel
#import "model/mark.typ": mark, marks
#import "style/styling.typ": style
#import "style/cycle.typ"
#import "style/color.typ"
#import "plot/plot.typ": plot
#import "plot/bar.typ": bar
#import "plot/hbar.typ": hbar
#import "plot/stem.typ": stem
#import "plot/hstem.typ": hstem
#import "plot/scatter.typ": scatter
#import "plot/fill-between.typ": fill-between
#import "plot/colormesh.typ": colormesh
#import "plot/contour.typ": contour
#import "plot/boxplot.typ": boxplot
#import "plot/hboxplot.typ": hboxplot
#import "plot/quiver.typ": quiver
#import "plot/hlines.typ": hlines
#import "plot/vlines.typ": vlines
#import "plot/rect.typ": rect
#import "plot/ellipse.typ": ellipse
#import "plot/line.typ": line
#import "plot/path.typ": path
#import "plot/place.typ": place
#import "style/tilings.typ"
#import "model/colorbar.typ": colorbar
#import "place-anchor.typ": place-anchor
#import "typing.typ": set-grid, set-label, set-title, set-legend, set-tick, set-tick-label, set-spine, set-diagram, set-errorbar, selector, fields, show_, cond-set
#import "logic/scale.typ"
#import "logic/tick-locate.typ" as tick-locate: linear as locate-ticks-linear, log as locate-ticks-log, symlog as locate-ticks-symlog, manual as locate-ticks-manual, subticks-linear as locate-subticks-linear, subticks-log as locate-subticks-log, subticks-symlog as locate-subticks-symlog, datetime as locate-ticks-datetime
#import "logic/tick-format.typ" as tick-format: linear as format-ticks-linear, log as format-ticks-log, manual as format-ticks-manual, symlog as format-ticks-symlog, datetime as format-ticks-datetime
#import "theme/theme.typ"

View File

@@ -0,0 +1,163 @@
/// Parses a CSV (comma-separated values) string. This function enhances the
/// functionality of the built-in Typst function [`csv`](https://typst.app/docs/reference/data-loading/csv/) with features like transforming
/// values to numerical (or other) types, ignoring comments and selecting only part of
/// the data.
///
/// Unlike the built-in `csv` function, this function returns the data as a list of
/// columns and not as a list of rows.
///
/// -> array | dictionary
#let load-txt(
/// Raw data loaded from a text file via [`read()`](https://typst.app/docs/reference/data-loading/read/).
/// -> str
data,
/// The delimiter that separates columns in the file.
/// -> str
delimiter: ",",
/// The characters that indicate the start of a single-line comment.
/// -> str
comments: "#",
/// The number of leading rows to be skipped, including comments.
/// -> int
skip-rows: 0,
/// Which columns to extract from the file. Expects an array of indices to columns to
/// extract. If `auto`, all columns are extracted.
/// -> auto | array
usecols: auto,
/// If true, the first line is interpreted as a header naming the individual columns.
/// The result is then returned as a dictionary with the headers.
/// -> bool
header: false,
/// Optional converter functions or types to use to convert the data entries. This
/// can either be a single function or type that is applied to all columns likewise
/// or a dictionary with column indices, or header names if enabled, as keys and functions or types as values.
/// Through the (optional) key `rest`, a default converter can be specified to be used
/// for all columns that have no explicit converter assigned.
/// -> function | type | dictionary
converters: float
) = {
let rows = data.split("\n")
.slice(skip-rows)
.filter(row => not (row.starts-with(comments) or row == ""))
rows = rows.map(row => row.split(delimiter))
if rows.len() == 0 { return () }
let len = rows.first().len()
for (i, row) in rows.enumerate() {
assert(row.len() == len, message: "All rows need to be of the same length but the row " + repr(row.join(delimiter)) + " does not have " + str(len) + " entries as the other ones. ")
}
if header {
header = rows.at(0).map(str.trim)
assert(header.dedup().len() == header.len(), message: "Duplicate entry in header")
rows = rows.slice(1)
}
let cols = array.zip(..rows)
if type(converters) == dictionary {
let default-converter = converters.at("rest", default: float)
if header != false {
for name in converters.keys() {
if name == "rest" { continue }
assert(name in header, message: "Found converter that doesn't map to a header: " + repr(name))
}
}
cols = range(cols.len()).map(j => {
let name = if header == false { str(j) } else { header.at(j) }
let converter = converters.at(name, default: default-converter)
cols.at(j).map(str.trim).map(converter)
})
} else {
assert(type(converters) in (function, type), message: "The converter needs to a function or a type")
cols = cols.map(col => col.map(str.trim).map(converters))
}
if header == false {
if usecols == auto { return cols }
if type(usecols) == int { usecols = (usecols,) }
assert(type(usecols) == array, message: "Parameter `usecols` expects an int or an array of ints")
return usecols.map(j => cols.at(j))
}
assert(usecols == auto, message: "Parameter `usecols` can not be used with `header: true`, You can just partially destructure the dictionary. ")
return header.zip(cols).to-dict()
}
#assert.eq(
load-txt("1,2,3\n4,5,6"),
((1,4), (2,5), (3,6))
)
#assert.eq(
load-txt("1, 2 , 3 \n 4, 5, 6"),
((1,4), (2,5), (3,6))
)
#assert.eq(
load-txt("\n1,2,3\n4,5,6\n\n"),
((1,4), (2,5), (3,6))
)
#assert.eq(
load-txt("\n1 2 3\n4 5 6", delimiter: " "),
((1,4), (2,5), (3,6))
)
#assert.eq(
load-txt("1,2,3\n//a\n4,5,6\n//a", comments: "//"),
((1,4), (2,5), (3,6))
)
#assert.eq(
load-txt("blablabla\n1,2\n3,4\n5,6", skip-rows: 1),
((1,3,5), (2,4,6))
)
#assert.eq(
load-txt(" n, a, b\n1,2,3\n4,5,6", header: true),
(n: (1,4), a: (2,5), b: (3,6))
)
#assert.eq(
load-txt("1,2,3\n4,5,6", usecols: 1),
((2,5),)
)
#assert.eq(
load-txt("1,2,3\n4,5,6", usecols: (0,2)),
((1,4), (3,6))
)
#assert.eq(
load-txt("1,2\n4,5", converters: v => v),
(("1","4"), ("2","5"))
)
#assert.eq(
load-txt("1,2\n4,5", converters: ("0": v => v)),
(("1","4"), (2,5))
)
#assert.eq(
load-txt("1,2\n4,5", converters: ("1": v => v)),
((1,4), ("2","5"))
)
#assert.eq(
load-txt("1,2\n4,5", converters: ("0": type, "1": v => v)),
((str, str), ("2","5"))
)
#assert.eq(
load-txt("1,2\n4,5", converters: ("0": float, rest: v => v)),
((1, 4), ("2","5"))
)
#assert.eq(
load-txt(" n, a, b\n1,2,3\n4,5,6", header: true, converters: (n: v => v, a: int, rest: v => v)),
(n: ("1","4"), a: (2,5), b: ("3","6"))
)

View File

@@ -0,0 +1,28 @@
#import "../math.typ": minmax
#import "process-coordinates.typ": is-data-coordinates
#let plot-lim(x, err: none) = {
if err == none { return minmax(x) }
return (
calc.min(..array.zip(x, err.m).map(((x, err)) => x - if type(err) == array { err.at(0) } else { err })),
calc.max(..array.zip(x, err.p).map(((x, err)) => x + if type(err) == array { err.at(1) } else { err })),
)
}
#let bar-lim(x, base) = {
let lim = minmax(x + base)
let (base-min, base-max) = minmax(base)
if lim.at(0) == base-min { lim.at(0) *= 1fr }
if lim.at(1) == base-max { lim.at(1) *= 1fr }
return lim
}
// ignores (relative) lengths and ratios and
// only accounts for data coordinates (which are
// given as floats).
#let compute-primitive-limits(coords) = {
let filtered-coords = coords.filter(is-data-coordinates)
if filtered-coords.len() == 0 { return none }
return (calc.min(..filtered-coords), calc.max(..filtered-coords))
}

View File

@@ -0,0 +1,182 @@
/// Takes an array of points as input and filters all points where at least one
/// coordinate is `calc.nan` to produce
/// + a filtered copy of the input array
/// + an array of consecutive "runs", i.e., all connected sequences from the input
/// separated by points where one or more coordinates take the value `calc.nan`.
///
/// -> array
#let filter-nan-points(
/// Input points. The points themselves may have any dimension.
/// -> array
points,
/// Whether to to split the data into separate runs whenever a coordinate
/// containing a `nan` value is encountered.
/// -> bool
generate-runs: false
) = {
if generate-runs {
let filtered-points = ()
let runs = ((),)
for coord in points {
if coord.find(float.is-nan) != none {
if runs.last().len() != 0 {
runs.push(())
}
continue
}
filtered-points.push(coord)
runs.last().push(coord)
}
return (filtered-points, runs)
} else {
return points.filter(p => p.find(float.is-nan) == none)
}
}
/// Converts an array of points to a step sequence.
/// Given $n$ points, the output will have $2n-1$ points and zero points
/// if the input has zero points.
///
/// -> array
#let stepify(
/// Input points of the form `(x, y)` with `nan` values removed.
/// -> array
points,
/// Step mode
/// - `start`: The interval $(x_{i-1}, x_i]$ takes the value of $x_i$.
/// - `end`: The interval $[x_i, x_{i+1})$ takes the value of $x_i$.
/// - `center`: The value switches half-way between consecutive $x$ positions.
/// -> start | center | end
step: start
) = {
if points.len() == 0 { return () }
let result = ()
if step == start {
for i in range(points.len() - 1) {
result.push(points.at(i))
result.push((points.at(i).at(0), points.at(i + 1).at(1)))
}
} else if step == end {
for i in range(points.len() - 1) {
result.push(points.at(i))
result.push((points.at(i + 1).at(0), points.at(i).at(1)))
}
} else if step == center {
for i in range(points.len() - 1) {
result.push(points.at(i))
let mid = 0.5 * (points.at(i).at(0) + points.at(i + 1).at(0))
result.push((mid, points.at(i).at(1)))
result.push((mid, points.at(i + 1).at(1)))
}
}
result.push(points.last())
return result
}
#assert.eq(stepify((), step: start), ())
#assert.eq(stepify(((0,0),), step: start), ((0,0),))
#assert.eq(stepify(((0,0), (1,.7)), step: start), ((0,0), (0,.7), (1,.7)))
#assert.eq(stepify(((0,0), (1,.7), (3,-.1)), step: start), ((0,0), (0,.7), (1,.7), (1,-.1), (3,-.1)))
#assert.eq(stepify((), step: end), ())
#assert.eq(stepify(((0,0),), step: end), ((0,0),))
#assert.eq(stepify(((0,0), (1,.7)), step: end), ((0,0), (1,0), (1,.7)))
#assert.eq(stepify(((0,0), (1,.7), (3,-.1)), step: end), ((0,0), (1,0), (1,.7), (3, .7), (3,-.1)))
#assert.eq(stepify((), step: center), ())
#assert.eq(stepify(((0,0),), step: center), ((0,0),))
#assert.eq(stepify(((0,0), (1,.7)), step: center), ((0,0), (0.5,0), (0.5, .7), (1,.7)))
#assert.eq(stepify(((0,0), (1,.7), (3,-.1)), step: center), ((0,0), (.5,0), (.5, .7), (1,.7), (2, .7), (2, -.1), (3,-.1)))
/// Transforms a generalized point
#let transform-point(x, y, transform) = {
if type(x) in (int, float) {
x = transform(x, 1).at(0)
}
if type(y) in (int, float) {
y = transform(1, y).at(1)
}
return (x, y)
}
#let convert-bezier-curve(points, transform) = {
let v = points.at(0)
let p = transform-point(..v, transform)
let result = (p,)
for (x, y) in points.slice(1) {
if type(x) in (int, float) {
x = transform(x + v.at(0), 1).at(0) - p.at(0)
}
if type(y) in (int, float) {
y = transform(1, y + v.at(1)).at(1) - p.at(1)
}
result.push((x, y))
}
return result
}
// point and size pt -> makes sense
// point and size data coords -> makes sense
// point data coords and size pt -> makes sense
// point data pt and size data coords -> makes no sense
#let convert-rect(
x,
y,
width,
height,
transform,
align: top + left
) = {
// at the end we only want (relative) lengths
let (x1, y1) = transform-point(x, y, transform)
if type(width) in (int, float) {
assert(type(x) in (int, float), message: "Setting the width in terms of data coordinates is only allowed if the origin x coordinate is given in data coordinates")
width = transform(x + width, 1).at(0) - transform(x, 1).at(0)
}
if type(height) in (int, float) {
assert(type(y) in (int, float), message: "Setting the height in terms of data coordinates is only allowed if the origin y coordinate is given in data coordinates")
height = transform(1, y + height).at(1) - transform(1, y).at(1)
}
if align.x == right { x1 -= width }
else if align.x == center { x1 -= width / 2 }
if align.y == bottom { y1 -= height }
else if align.y == horizon { y1 -= height / 2 }
return (x1, width, y1, height)
}
#let is-data-coordinates(coord) = type(coord) in (int, float)
#let all-data-coordinates(coords) = {
return coords.map(is-data-coordinates).fold(true, (a, b) => a and b)
}
// Convert a vertex for `path`. A vertex may either be a single vertex
// or a pair/triple of vertices describing a point with handles on a
// bezier curve.
#let convert-vertex(v, transform: it => it) = {
if type(v.at(0)) == array {
return v.map(p => transform-point(..p, transform))
} else {
transform-point(..v, transform)
}
}

View File

@@ -0,0 +1,58 @@
#import "../utility.typ": match-type
#import "../math.typ" as pmath
#import "../logic/scale.typ"
#import "../logic/transform.typ": create-trafo
#let sample-colors(
values,
colormap,
norm,
min: auto,
max: auto,
ignore-nan: false,
excess: "clamp" // "clamp" | "mask"
) = {
if ignore-nan {
if min == auto { min = pmath.cmin(values) }
if max == auto { max = pmath.cmax(values) }
} else {
if min == auto { min = calc.min(..values) }
if max == auto { max = calc.max(..values) }
}
if min == max { min -= 1; max += 1}
if excess == "mask" {
values = values.map(v => if v < min or v > max { float.nan } else { v })
} else if excess == "clamp" {
// values = values.map(v => if v < min { min } else if v > max { max } else { v })
}
let norm-fn = match-type(
norm,
function: () => norm,
string: () => scale.scales.at(norm).transform,
dictionary: () => {
assert("transform" in norm, message: "The argument `norm` must be a valid scale from the `scales` module")
norm.transform
},
default: () => assert(false, message: "Unsupported type `" + str(type(norm)) + "` for argument `norm`")
)
let normalize = create-trafo(norm-fn, min, max)
assert(type(colormap) in (gradient, array), message: "Invalid type for colormap")
if type(colormap) == array {
colormap = gradient.linear(..colormap)
}
let convert-scalar-to-color(x) = {
if float.is-nan(x) { return luma(0, 0%) }
colormap.sample(normalize(x) * 100%)
}
(
values.map(convert-scalar-to-color),
(norm: norm, min: min, max: max, colormap: colormap)
)
}

View File

@@ -0,0 +1,174 @@
/// Scales that can be applied to the displayed data. By default, the
/// data is scaled linearly but other scales like `lq.scale.log` and
/// `lq.scale.symlog` are available and completely custom scales can
/// be created with the general `scale` function.
#import "../math.typ": sign
#import "../logic/tick-locate.typ"
/// Constructor for the scale type. Scales are used to transform data coordinates
/// into scaled coordinates. Commonly, data is displayed with a linear scale, i.e.,
/// the entire data is just scaled uniformly. However, in order to visualize large
/// ranges, it is often desirable to use logarithmic scaling or other scales that
/// improve the readability of the data.
#let scale(
/// Transformation from data coordinates to scaled coordinates.
/// Note that the transformation function does not need to worry about
/// absolute scaling and offsets. As an example, the transformation function
/// for the linear scale is just `x => x` and not something like
/// `x => a * x + b`. The logarithmic scale uses `x => calc.log(x)`.
/// -> function
transform,
/// A precise inverse of the `transform` in order to enable the conversion of
/// scaled coordinates back to data coordinates.
/// -> function
inverse,
/// Name of the scale. Built-in scales are sometimes identified by their name,
/// e.g., when a suitable tick locator needs to be selected automatically.
/// -> str
name: "",
/// An identity value which can be used to find an initial axis range when
/// no limits or plots are given. Scales like logarithmic scales that are
/// only defined for positive values should set this to 1.
/// -> int | float
identity: 0,
/// The default tick locator to use with this scale.
/// -> none |function
locate-ticks: none,
/// The default subtick locator to use with this scale.
/// -> none |function
locate-subticks: none,
/// Additional data to store in the scale.
/// -> any
..args
) = (
transform: transform,
inverse: inverse,
name: name,
identity: identity,
locate-ticks: locate-ticks,
locate-subticks: locate-subticks,
..args.named()
)
/// Creates a new linear scale. This scale can also be accessed through
/// the shorthand `"linear"`.
#let linear() = scale(
name: "linear",
x => x,
x => x,
locate-ticks: tick-locate.linear,
locate-subticks: tick-locate.subticks-linear,
)
/// Creates a new logarithmic scale. This scale can also be accessed through
/// the shorthand `"log"`.
#let log(
/// The base of the logarithm. This info is only used to determine
/// the base for ticks.
/// -> float
base: 10
) = scale(
name: "log",
x => calc.log(x),
x => calc.pow(10., x),
base: base,
identity: 1,
locate-ticks: tick-locate.log.with(base: base),
locate-subticks: tick-locate.subticks-log.with(base: base),
)
/// Creates a new symlog scale with a linear scaling in the region
/// `[threshold, threshold]` around 0 and a logarithmic scaling beyond that.
/// This scale can also be accessed through the shorthand `"symlog"`.
#let symlog(
/// The base of the logarithm.
/// -> float
base: 10,
/// The threshold for the linear region.
/// -> float
threshold: 1,
/// The scaling of the linear region.
/// -> float
linscale: 1
) = {
let c = linscale / (1. - 1. / base)
import "symlog.typ": symlog-transform
let transform = symlog-transform(base, threshold, linscale)
let inv-threshold = transform(threshold)
scale(
name: "symlog",
transform,
x => {
if x == 0 { return 0. }
let abs = calc.abs(x)
if abs <= inv-threshold { return x / c }
return sign(x) * threshold * calc.pow(base, abs / threshold - c)
},
threshold: threshold,
base: base,
linscale: linscale,
locate-ticks: tick-locate.symlog.with(
base: base,
threshold: threshold,
linscale: linscale
),
locate-subticks: tick-locate.subticks-symlog.with(
base: base,
threshold: threshold
)
)
}
#let check-sym(x) = {
let sym = symlog()
assert((sym.inverse)((sym.transform)(x)) - x < 1e-15)
}
#check-sym(0)
#check-sym(1)
#check-sym(1.5)
#check-sym(2)
#check-sym(3)
/// A linear scale with a datetime tick locator. This scale can also be
/// accessed through the shorthand "datetime".
#let datetime() = {
scale(
name: "datetime",
x => x,
x => x,
locate-ticks: tick-locate.datetime,
locate-subticks: none,
)
}
#let scales = (
linear: linear(),
log: log(base: 10),
symlog: symlog(base: 10),
datetime: datetime()
)

View File

@@ -0,0 +1,14 @@
#import "../math.typ": sign
#let symlog-transform(base, threshold, linscale) = {
let c = linscale / (1. - 1. / base)
let log-base = calc.ln(base)
x => {
if x == 0 { return 0. }
let abs = calc.abs(x)
if abs <= threshold { return x * c }
return sign(x) * threshold * (c + calc.ln(abs / threshold) / log-base)
}
}

View File

@@ -0,0 +1,618 @@
#import "@preview/zero:0.5.0"
#import "../math.typ": pow10
#import "tick-locate.typ": _estimate-significant-digits
#import "../logic/time.typ"
/// Formats ticks with explicit labels. See @tick-locate.manual.
#let manual(
/// The ticks to format
/// -> array
ticks,
/// Additional information from the tick locator.
/// -> dictionary
tick-info: (:),
/// Arguments that are ignored by this formatter.
/// -> any
..args
) = {
assert(
"labels" in tick-info, message: "No labels given to `tick-format.manual`"
)
tick-info.labels
}
#let naive(
ticks,
..args
) = ticks.map(str)
#let num(
value,
sign: 1,
e: none,
auto-e: true,
digits: auto,
base: 10,
omit-unit-mantissa: true
) = {
if digits != auto {
digits = calc.max(0, digits)
}
if e == 0 and auto-e { e = none }
if e != none {
e = str(e)
}
zero.num(
(
mantissa: str(value).replace(sym.minus, "-"),
pm: none,
e: e
),
base: base,
digits: digits,
omit-unity-mantissa: omit-unit-mantissa
)
}
#let default-generate-tick-label(
value,
exponent: 0,
digits: auto,
simple: false
) = {
if simple {
if exponent == 0 { return $#value$ }
return $#value dot 10^#exponent$
}
if exponent == 0 { exponent = none }
return num(value, e: exponent, digits: digits)
}
/// Formats linear ticks, see @tick-locate.linear. This is the most common tick
/// formatter.
#let linear(
/// The ticks to format.
/// -> array.
ticks,
/// The exponent to apply to the ticks.
/// -> auto | int | "inline"
exponent: auto,
/// The offset to apply to the ticks.
/// -> auto | int | float
offset: auto,
/// Determines the threshold for automatic exponents to kick in.
/// -> int
auto-exponent-threshold: 3,
/// A suffix to display with each tick label.
/// -> content
suffix: none,
/// Additional information from the tick locator.
/// -> dictionary
tick-info: (:),
/// Arguments that are ignored by this formatter.
/// -> any
..args
) = {
let unit = tick-info.at("unit", default: 1)
if offset == auto {
offset = tick-info.at("offset", default: 0)
} else {
assert(type(offset) in (int, float), message: "Offsets need to be of integer or float type")
}
let additional-exponent = 0 // extra exp that will be shown on the axis
let inline-exponent = 0 // extra exp that is shown for each tick
let inherited-exponent = tick-info.at("exponent", default: 0)
if exponent == none { exponent = 0 }
if exponent == auto {
if calc.abs(inherited-exponent) >= auto-exponent-threshold {
additional-exponent = inherited-exponent
}
} else if exponent == "inline" {
if calc.abs(inherited-exponent) >= auto-exponent-threshold {
inline-exponent = inherited-exponent
}
} else {
assert.eq(type(exponent), int, message: "Exponents need to be of integer type")
additional-exponent = exponent
}
let preapplied-exponent = inline-exponent + additional-exponent
let preapplied-factor = 1 / pow10(preapplied-exponent) / unit
let ticks = ticks.map(x => (x - offset*unit) * preapplied-factor)
let significant-digits = tick-info.at("significant-digits", default: none)
if significant-digits == none {
significant-digits = _estimate-significant-digits(ticks)
} else {
significant-digits += preapplied-exponent
}
let labels = ticks
.map(calc.round.with(digits: significant-digits))
.map(num.with(
e: inline-exponent,
digits: significant-digits
)
)
if suffix != none {
labels = ticks.zip(labels).map(((tick, label)) => {
if tick == 0 { return $0$ }
tick = calc.round(tick, digits: 3)
if tick == 1 { label = none }
else if tick == -1 { label = ""}
label + suffix
})
}
(
labels: labels,
exponent: additional-exponent,
offset: offset
)
}
/// Formats logarithmic ticks, see @tick-locate.log.
#let log(
/// The ticks to format.
/// -> array.
ticks,
/// The base of the logarithm.
/// -> int | float
base: 10,
/// Whether to use a fixed exponent and which one.
/// -> auto | int
exponent: auto,
auto-exponent-threshold: 3,
round-exponent-digits: 4,
/// Which base to display with the ticks. If `auto`, the base is inferred
/// from `base`.
/// -> auto | content
base-label: auto,
/// Additional information from the tick locator.
/// -> dictionary
tick-info: (:),
/// Arguments that are ignored by this formatter.
/// -> any
..args
) = {
// Sometimes the log ticker resorts to linear ticking and then this is better
if "linear" in tick-info and tick-info.linear {
return linear(
ticks,
tick-info: tick-info,
exponent: exponent,
auto-exponent-threshold, auto-exponent-threshold
)
}
if base-label == auto {
if base == calc.e { base-label = $e$}
else { base-label = base }
}
if exponent == auto {
let num = num.with(omit-unit-mantissa: true, base: base-label, auto-e: false)
ticks.map(x =>
num(
float.signum(x),
e: calc.round(calc.log(calc.abs(x), base: base),
digits: round-exponent-digits)
)
)
} else {
ticks.map(num)
}
}
/// Formats symlog ticks, see @tick-locate.symlog.
#let symlog(
/// The ticks to format.
/// -> array.
ticks,
/// The base of the logarithm.
/// -> int | float
base: 10,
/// The threshold where the scale switches between linear and logarithmic.
/// -> float
threshold: 1,
/// Whether to use a fixed exponent and which one.
/// -> auto | int
exponent: auto,
auto-exponent-threshold: 3,
round-exponent-digits: 4,
/// Which base to display with the ticks. If `auto`, the base is inferred
/// from `base`.
/// -> auto | content
base-label: auto,
/// Additional information from the tick locator.
/// -> dictionary
tick-info: (:),
/// Arguments that are ignored by this formatter.
/// -> any
..args
) = {
let upper-log = ticks.filter(tick => tick >= threshold or tick <= -threshold)
let log = log(
upper-log,
tick-info: tick-info,
base: base,
base-label: base-label,
exponent: exponent,
auto-exponent-threshold, auto-exponent-threshold
)
let linear = linear(
ticks.filter(tick => tick < threshold and tick > -threshold)
)
log + linear.labels
}
/// Displays a smart first of a period (month, day, hour, minute, or second).
#let datetime-smart-first(
/// The time to display.
/// -> datetime
time,
/// Which first to display, e.g., `"month"` for the first month of a year.
/// -> str
period: "month",
/// What to display for the first month in a year.
/// -> str | function
month: "[year]",
/// What to display for the first day in a month.
/// -> str | function
day: "[month repr:short]",
/// What to display for the first hour of a day.
/// -> str | function
hour: "[month repr:short]-[day]",
/// What to display for the first minute of an hour.
/// -> str | function
minute: "[month repr:short]-[day]",
/// What to display for the first second of a minute.
/// -> str | function
second: "[month repr:short]-[day]",
) = {}
#import "@preview/elembic:1.1.1" as e
#let datetime-smart-first = e.element.declare(
"datetime-smart-first",
prefix: "lilaq",
display: it => {
let format = it.at(it.period)
if type(format) == str { it.time.display(format) }
else { format(it.time) }
},
fields: (
e.field("time", datetime, required: true),
e.field("period", str, default: "month"),
e.field("month", e.types.union(str, function), default: "[year]"),
e.field("day", e.types.union(str, function), default: "[month repr:short]"),
e.field("hour", e.types.union(str, function), default: "[month repr:short]-[day]"),
e.field("minute", e.types.union(str, function), default: "[month repr:short]-[day]"),
e.field("second", e.types.union(str, function), default: "[month repr:short]-[day]"),
)
)
/// Displays a datetime tick.
#let datetime-smart-format(
/// The date/time to display.
/// -> datetime
time,
/// Whether to use @datetime-smart-first for first instances in a period.
/// -> bool
smart-first: true,
/// The smallest changing period type between consecutive ticks.
/// -> str
period: "month",
/// How to display years.
/// -> str | function
year: "[year]",
/// How to display months.
/// -> str | function
month: "[month repr:short]",
/// How to display days.
/// -> str | function
day: "[day]",
/// How to display hours.
/// -> str | function
hour: "[hour]:[minute]",
/// How to display minutes.
/// -> str | function
minute: "[hour]:[minute]",
/// How to display seconds.
/// -> str | function
second: "[hour]:[minute]:[second]",
) = {}
#let datetime-smart-format = e.element.declare(
"datetime-smart-format",
prefix: "lilaq",
display: it => {
if it.period == none {
return it.datetime.display()
}
let first = if it.period in ("month", "day") { 1 } else { 0 }
let component = (
"year": dt => false,
"month": dt => dt.month() == 1,
"day": dt => dt.day() == 1,
"hour": dt => dt.hour() == 0,
"minute": dt => dt.hour() == 0 and dt.minute() == 0,
"second": dt => dt.hour() == 0 and dt.minute() == 0 and dt.second() == 0,
).at(it.period)
if it.smart-first and component(it.datetime) and it.period != "year" {
datetime-smart-first(it.datetime, period: it.period)
} else {
let format = it.at(it.period)
if type(format) == str { it.datetime.display(format) }
else { format(it.datetime) }
}
},
fields: (
e.field("datetime", datetime, required: true),
e.field("smart-first", bool, default: true),
e.field("period", e.types.option(str), default: "month"),
e.field("year", e.types.union(str, function), default: "[year]"),
e.field("month", e.types.union(str, function), default: "[month repr:short]"),
e.field("day", e.types.union(str, function), default: "[day]"),
e.field("hour", e.types.union(str, function), default: "[hour]:[minute]"),
e.field("minute", e.types.union(str, function), default: "[hour]:[minute]"),
e.field("second", e.types.union(str, function), default: "[hour]:[minute]:[second]"),
)
)
#let display-datetime-smart-offset = (it, smart-first: true) => {
if it.period == none {
return none
}
let period(datetime, period) = {
let format = it.at(period)
if type(format) == str { datetime.display(format) }
else { format(datetime) }
}
let first = it.ticks.first()
let last = it.ticks.last()
if it.period == "month" {
let has-no-first = first.month() != 1 or not smart-first
if (first.year() == last.year() and has-no-first) or not it.avoid-redundant {
period(first, "year")
}
} else if it.period == "day" {
let has-no-first = first.day() != 1 or not smart-first
if (first.year() == last.year() and first.month() == last.month() and has-no-first) or not it.avoid-redundant {
period(first, "month")
} else if first.year() == last.year() {
period(first, "year")
}
} else if it.period in ("hour", "minute", "second") {
if first.year() == 0 { return }
let has-no-first = first.hour() != 0 or not smart-first
if (first.year() == last.year() and first.month() == last.month() and first.day() == last.day() and has-no-first) or not it.avoid-redundant {
period(first, "day")
} else if first.year() == last.year() {
period(first, "year")
}
}
}
/// Displays an offset for a set of datetime ticks.
#let datetime-smart-offset(
/// The ticks as `datetime` instances.
/// -> array
ticks,
/// Whether to avoid redundant information between ticks and offset.
/// For example, if the year is already displayed as a @datetime.smart-fist,
/// it is omitted here.
/// -> bool
avoid-redundant: true,
/// The smallest changing period type between consecutive ticks.
/// -> str
period: "month",
/// How to display years offsets.
/// -> str | function
year: "[year]",
/// How to display month offsets.
/// -> str | function
month: "[year]-[month repr:short]",
/// How to display day offsets.
/// -> str | function
day: "[year]-[month repr:short]-[day]"
) = {}
#let datetime-smart-offset = e.element.declare(
"datetime-smart-offset",
prefix: "lilaq",
display: it => e.get(e-get =>
display-datetime-smart-offset(it, smart-first: e-get(datetime-smart-format).smart-first)
),
fields: (
e.field("ticks", e.types.array(datetime), required: true),
e.field("avoid-redundant", bool, default: true),
e.field("period", e.types.option(str), default: "month"),
e.field("year", e.types.union(str, function), default: "[year]"),
e.field("month", e.types.union(str, function), default: "[year]-[month repr:short]"),
e.field("day", e.types.union(str, function), default: "[year]-[month repr:short]-[day]"),
)
)
/// Formats datetime ticks.
#let datetime(
/// The ticks to format.
/// -> array.
ticks,
/// How to format the ticks. This can be a format string to be used with
/// #link(https://typst.app/docs/reference/foundations/datetime/#definitions-display)[`datetime.display`]
/// or a function that receives a datetime.
/// -> str | function
format: datetime-smart-format,
/// How to format the offset.
/// -> function
format-offset: datetime-smart-offset,
/// Additional information from the tick locator.
/// -> dictionary
tick-info: (:),
/// Arguments that are ignored by this formatter.
/// -> any
..args
) = {
assert(
"mode" in tick-info,
message: "datetime can only be used with a datetime tick locator"
)
let period = tick-info.at("period", default: none)
let datetimes = time.to-datetime(
..ticks,
mode: if period == none { tick-info.mode } else { "datetime" }
)
// let offset-datetime = if min > max {
// datetimes.first()
// } else {
// datetimes.last()
// }
let offset = format-offset(datetimes, period: period)
let labels = if type(format) == function {
datetimes.map(dt => format(dt, period: period))
} else if type(format) == str {
datetimes.map(dt => dt.display(format))
}
(
labels: labels,
exponent: 0,
offset: offset
)
}
#let set-datetime-smart-format = e.set_.with(datetime-smart-format)
#let set-datetime-smart-first = e.set_.with(datetime-smart-first)
#let set-datetime-smart-offset = e.set_.with(datetime-smart-offset)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
#let reference-datetime = datetime(year: 0, month: 1, day: 1, hour: 0, minute: 0, second: 0)
#let reference-time = datetime(hour: 0, minute: 0, second: 0)
#let reference-date = datetime(year: 0, month: 1, day: 1)
#let to-seconds(..datetimes, return-mode: false) = {
datetimes = datetimes.pos()
if datetimes.len() == 0 { return () }
assert(
datetimes.all(dt => type(dt) == datetime),
message: "When passing datetime values, all entries need to be of type datetime"
)
let reference
let mode
let dt = datetimes.first()
let assert-same-type = assert.with(
message: "All datetimes must be of the same type (all time, all date, or all datetime)."
)
if dt.hour() == none {
mode = "date"
reference = reference-date
assert-same-type(datetimes.all(dt => dt.hour() == none))
} else if dt.year() == none {
mode = "time"
reference = reference-time
assert-same-type(datetimes.all(dt => dt.year() == none))
} else {
mode = "datetime"
reference = reference-datetime
assert-same-type(datetimes.all(dt => dt.hour() != none and dt.year() != none))
}
let seconds = datetimes.map(dt => (dt - reference).seconds())
if return-mode {
(mode: mode, seconds: seconds)
} else {
seconds
}
}
#let to-datetime(..seconds, mode: "datetime") = {
seconds = seconds.pos()
let reference = if mode == "datetime" {
reference-datetime
} else if mode == "date" {
reference-date
} else if mode == "time" {
reference-time
} else {
assert(false, "Unsupported mode")
}
seconds.map(seconds => reference + duration(seconds: int(seconds)))
}
#to-seconds(
datetime(year: 2025, month: 8, day: 4),
datetime(year: 2025, month: 8, day: 7),
// reference-datetime
)
#to-seconds(
// reference-time,
reference-datetime,
reference-datetime
)
#let with(
date,
year: auto, month: auto, day: auto,
hour: auto, minute: auto, second: auto
) = {
if year == auto { year = date.year() }
if month == auto { month = date.month() }
if day == auto { day = date.day() }
if hour == auto { hour = date.hour() }
if minute == auto { minute = date.minute() }
if second == auto { second = date.second() }
let has-date = (year, month, day).any(x => x != none)
let has-time = (hour, minute, second).any(x => x != none)
if has-date and has-time {
datetime(
year: year,
month: month,
day: day,
hour: hour,
minute: minute,
second: second
)
} else if has-date {
datetime(
year: year,
month: month,
day: day
)
} else {
datetime(
hour: hour,
minute: minute,
second: second
)
}
}
#assert.eq(
with(datetime(year: 2002, month: 3, day: 3), year: 1999),
datetime(year: 1999, month: 3, day: 3)
)
#assert.eq(
with(datetime(year: 2002, month: 3, day: 3), year: 1999, month: 12),
datetime(year: 1999, month: 12, day: 3)
)
#assert.eq(
with(datetime(year: 2002, month: 3, day: 3), day: 31, month: 12),
datetime(year: 2002, month: 12, day: 31)
)
#assert.eq(
with(datetime(hour: 2, minute: 22, second: 8), second: 7),
datetime(hour: 2, minute: 22, second: 7)
)
#assert.eq(
with(datetime(hour: 2, minute: 22, second: 8), minute: 27),
datetime(hour: 2, minute: 27, second: 8)
)
#assert.eq(
with(datetime(hour: 2, minute: 22, second: 8), hour: 7),
datetime(hour: 7, minute: 22, second: 8)
)

View File

@@ -0,0 +1,44 @@
/// Creates a data-to-display transformation that maps the interval
/// $[x_0, x_1]$ to $[y_0, y_1]$ given some monotonous transformation function
/// (e.g., identity or log(x)).
///
/// -> function
#let create-trafo(
/// Transformation function that takes one argument.
/// -> function
transform,
/// Lower interval bound.
/// -> float
x0,
/// Upper interval bound.
/// -> float
x1,
/// Lower target interval bound.
/// -> float
y0: 0,
/// Upper target interval bound.
/// -> float
y1: 1
) = {
let a = (y1 - y0) / (transform(x1) - transform(x0))
let b = -a * transform(x0) + y0
x => a * transform(x) + b
}
#assert.eq(create-trafo(x => x, -23, 4)(-23), 0)
#assert.eq(create-trafo(x => x, -23, 4)(4), 1)
#assert.eq(create-trafo(x => x, -23, 4, y0: 1, y1: -1)(-23), 1)
#assert.eq(create-trafo(x => x, -23, 4, y0: 1, y1: -1)(4), -1)
#assert.eq(create-trafo(x => calc.log(x), 2, 4)(2), 0)
#assert.eq(create-trafo(x => calc.log(x), 2, 4)(4), 1)

View File

@@ -0,0 +1,332 @@
#import "vec.typ"
#import "algorithm/interpolate.typ"
#import "loading/txt.typ": load-txt
/// Returns the sign of a number.
/// - `0` for $x=0$
/// - `1` for $x>0$
/// - `-1` for $x<0$
/// Note that this is different from the built-in
/// [`float.signum()`](https://typst.app/docs/reference/foundations/float/#definitions-signum)
/// which returns `1.0` for $x=0$.
/// -> int
#let sign(
/// The value to determine the sign of.
/// -> int | float
value
) = if value == 0 { 0 } else if value < 0 { -1 } else { 1 }
/// Returns the minimum value of an array, ignoring `NaN` values. Returns `none`
/// if the array is empty or contains only `NaN` values.
/// -> none | float
#let cmin(
/// Values to compute the minimum of.
/// -> array
values
) = {
values = values.filter(x => not float.is-nan(x))
if values.len() == 0 { return none }
return calc.min(..values)
}
/// Returns the maximum value of an array, ignoring `NaN` values. Returns `none`
/// if the array is empty or contains only `NaN` values.
/// -> none | float
#let cmax(
/// Values to compute the maximum of.
/// -> array
values
) = {
values = values.filter(x => not float.is-nan(x))
if values.len() == 0 { return none }
return calc.max(..values)
}
/// Returns the minimum and maximum value of an array, ignoring `NaN` values.
/// Returns `(none, none)` if the array is empty or contains only `NaN` values.
/// -> array
#let minmax(
/// Values to compute the minimum and the maximum of.
/// -> array
values
) = {
values = values.filter(x => not float.is-nan(x))
if values.len() == 0 { return (none, none) }
return (calc.min(..values), calc.max(..values))
}
#assert.eq(cmin((1,2,3,4)), 1)
#assert.eq(cmin((1,2,3,-23.2)), -23.2)
#assert.eq(cmin((float.nan,2,3,-23.2)), -23.2)
#assert.eq(cmin((float.nan,)), none)
#assert.eq(cmax((1,2,3,4)), 4)
#assert.eq(cmax((-1,-2,-3,-23.2)), -1)
#assert.eq(cmax((float.nan,2,3,-23.2)), 3)
#assert.eq(cmax((float.nan,)), none)
#assert.eq(float.is-nan(float.nan), true)
#assert.eq(float.is-nan(1e123), false)
#assert.eq(float.is-nan(0), false)
#assert.eq(float.is-nan(-1232445345345e200000000000000000), false)
/// Generates an array of evenly-spaced numbers in the interval `[start, end)` or `[start, end]`.
/// -> array
#let linspace(
/// Start of the range.
/// -> int | float
start,
/// End of the range.
/// -> int | float
end,
/// Number of evenly-spaced values to produce.
/// -> int
num: 50,
/// Whether to include the end of the range. If `true`, samples are taken for
/// the closed interval `[start, end]`.
/// -> bool
include-end: true
) = {
assert(num >= 0, message: "linspace: num must be non-negative")
if num == 0 { return () }
if num == 1 { return (start,) }
let k = (end - start) / (num - int(include-end))
range(num).map(x => k * x + start)
}
// Special cases: num = 0,1
#assert.eq(linspace(0, 1, num: 0), ())
#assert.eq(linspace(0, 1, num: 0, include-end: false), ())
#assert.eq(linspace(-2.3, 1, num: 1), (-2.3, ))
#assert.eq(linspace(-2.3, 1, num: 1, include-end: false), (-2.3, ))
// Normal operation
#assert.eq(linspace(0, 1, num: 2), (0, 1))
#assert.eq(linspace(0, 1, num: 2, include-end: false), (0, .5))
#assert.eq(linspace(-3.4, 7, num: 2), (-3.4, 7))
#assert.eq(linspace(-3, 7, num: 2, include-end: false), (-3, 2))
#assert.eq(linspace(0, 1, num: 5), (0, .25, .5, .75, 1))
// Inverse range
#assert.eq(linspace(1, 0, num: 2), (1, 0))
#assert.eq(linspace(100, 0, num: 2), (100, 0))
#assert.eq(linspace(100, 0, num: 2, include-end: false), (100, 50))
/// Generates an array of numbers spaced by `step` in the interval `[start, end)`.
/// -> array
#let arange(
/// Start of the range.
/// -> int | float
start,
/// End of the range (excluded).
/// -> int | float
end,
/// Difference between consecutive values.
/// -> int | float
step: 1
) = {
let num = calc.quo(end - start, step)
range(num).map(x => x * step + start)
}
#let arange1(
start,
end,
step: 1,
) = {
let num = calc.quo(calc.abs(end - start), step)
range(num).map(x => x * step + start)
}
#assert.eq(arange(0, 1), (0,))
#assert.eq(arange(0, 2), (0,1))
#assert.eq(arange(0, 1, step: 0.25), (0,.25, .5, .75))
#assert.eq(arange(0, 2, step: 0.25), (0,.25, .5, .75, 1, 1.25, 1.5, 1.75))
// Inverse range
#assert.eq(arange(1, 0), ())
#assert.eq(arange(41, 0), ())
#assert.eq(arange(1, 0, step: -1), (1,))
#assert.eq(arange(0, -4, step: -1), (0, -1, -2, -3))
#assert.eq(arange(1, 0, step: -0.25), (1,.75, .5, .25))
/// Computes the q-th percentile of the given data.
#let percentile(
/// Array of values.
/// -> array
values,
/// A percentage between 0% and 100%.
/// -> ratio
q,
/// Interpolation method. Currently, only `"linear"` is supported.
/// -> "linear"
method: "linear"
) = {
assert(method in ("linear",), message: "`percentile`: unknown method \"" + method + "\"")
return interpolate.linear(values, q / 100% * (values.len() - 1))
}
#assert.eq(percentile((1,2,3), 50%), 2)
#assert.eq(percentile((1,2,3), 25%), 1.5)
#assert.eq(percentile((1,2,3), 0%), 1)
#assert.eq(percentile((1,2,3), 100%), 3)
/// Creates a rectangular mesh from two input arrays.
/// One or more functions are evaluated for each possible pair $(x_i,y_j)$ of
/// the inputs $\{x_0,...\}$ and $\{y_0,...\}$.
/// ```example
/// #lq.mesh((0, 1), (4, 5), (x, y) => (x + y))
/// ```
/// Returns the array `(x, y, ..zs)` where `zs` are two-dimensional arrays
/// ```
/// (
/// (f(x0, y0), f(x0, y1), ...),
/// (f(x1, y0), f(x1, y1), ...),
/// ...
/// )
/// ```
/// for each function that was passed to `mesh()`.
/// -> array
#let mesh(
/// Array of $x$ coordinates.
/// -> array
x,
/// Array of $y$ coordinates.
/// -> array
y,
/// Bivariate functions that take two numbers `x, y` as inputs.
/// -> function
..transforms
) = {
let transforms = transforms.pos()
let mesh = transforms.map(transform => y.map(y => x.map(x => transform(x, y))))
if transforms.len() == 1 {
mesh = mesh.first()
}
return mesh
}
#assert.eq(
mesh((0, 2), (4, 5), (x, y) => (x + y/10)),
((0.4, 2.4), (0.5, 2.5))
)
#assert.eq(
mesh((0, 2), (4, 5), (x, y) => (x + y/10), (x, y) => (x - y)),
(
((0.4, 2.4), (0.5, 2.5)),
((-4, -2), (-5, -3)),
)
)
/// Performs integer (floored) division and returns `(quotient, remainder)` while
/// guaranteeing that `quotient * divisor + remainder = dividend`. Note that using
/// `calc.quo` and `calc.rem` does not give this guarantee.
/// -> array
#let divmod(
/// The dividend of the quotient.
/// -> int | float
dividend,
/// The divisor of the quotient.
/// -> int | float
divisor
) = {
let q = calc.quo(dividend, divisor)
return (q, dividend - q * divisor)
}
#assert.eq(divmod(5, 2), (2, 1))
#assert.eq(divmod(5, .5), (10, 0))
#assert.eq(divmod(5.25, .5), (10, 0.25))
#assert.eq(divmod(5.25, -.5), (-11, -0.25))
#assert.eq(divmod(5.25, -.5), (-11, -0.25))
#assert.eq(divmod(-5.25, .5), (-11, 0.25))
#assert.eq(divmod(-5.25, -.5), (10, -0.25))
#assert.eq(divmod(2, 1), (2, 0))
#assert.eq(divmod(-2, 1), (-2, 0))
#assert.eq(divmod(1, .2), (5, 0))
#assert.eq(divmod(5, .2), (25, 0))
#{
let check(x, d) = {
let quo = calc.div-euclid(x, d)
let rem = calc.rem(x, d)
let (quo, rem) = divmod(x, d)
assert.eq(quo*d + rem, x)
}
check(5, -.2)
check(2, 1)
check(-2, 1)
check(1, .2)
check(-5, .2)
check(-5, -.2)
check(115, 22)
check(115, .22)
check(1e30, 1e20)
}
/// Decomposes a floating point number into the scientific notation.
/// $ x = a\cdot 10^n $
/// where $a \in [0.1, 1)$ and $n \in \mathbb{Z}$. Returns an array `(a, n)`.
/// -> array
#let decompose-floating-point(
/// Number to decompose.
/// -> float
value
) = {
let n = int(calc.floor(calc.log(base: 10, value))) + 1
let a = value / calc.pow(10., n)
return (a, n)
}
/// Computes $10^x$ for the given number $x$, guaranteeing floating point
/// computation, even when the input is an `int`.
/// -> float
#let pow10(
/// The exponent to which to raise the number 10.
/// -> int | float
value
) = calc.pow(10., value)

View File

@@ -0,0 +1,933 @@
#import "../logic/scale.typ" as lqscale
#import "../utility.typ": place-in-out, match, match-type, if-auto, if-none
#import "../logic/time.typ"
#import "../logic/tick-locate.typ"
#import "../logic/tick-format.typ"
#import "../bounds.typ": *
#import "../assertations.typ"
#import "../model/label.typ": xlabel, ylabel, label as lq-label
#import "../process-styles.typ": update-stroke, merge-strokes
#import "@preview/elembic:1.1.1" as e
#import "tick.typ": tick as lq-tick, tick-label as lq-tick-label
#import "spine.typ": spine
#import "@preview/zero:0.5.0"
#import "@preview/tiptoe:0.3.1"
/// An axis for a diagram. Visually, an axis consists of a _spine_ along the axis
/// direction, a collection of ticks/subticks and an axis label.
///
/// By default, a @diagram features two axes: an `x` and a `y` axis which can be
/// configured directly through @diagram.xaxis and @diagram.yaxis. However, it is
/// also possible to add more axes, please refer to the
/// #link("tutorials/axis")[axis tutorial] for more details.
///
/// The built-in tick formatters use the Typst package
/// #link("https://typst.app/universe/package/zero")[Zero] for displaying
/// numbers. This makes it possible to define a consistent number format
/// throughout the entire document, including tables, in-text quantities,
/// and figures.
#let axis(
/// Sets the scale of the axis. This may be a @scale object or the name of
/// one of the built-in scales `"linear"`, `"log"`, `"symlog"`, and
/// `"datetime"`.
///
/// If left at `auto`, the scale will be set to `"datetime"` if any of the
/// plots uses datetime coordinates and `"linear"` otherwise.
/// -> auto | str | lq.scale
scale: auto,
/// Data limits of the axis. This can be used to fix the minimum and/or maximum value
/// displayed along this axis. This parameter expects `auto` or a tuple `(min, max)`
/// where `min` and `max` can also be `auto`. If a limit is `auto`, it will be
/// automatically computed from all plots associated with this axis and @diagram.margin
/// will be applied. If the minimum is larger than the maximum, the scale is inverted
/// and if `min` and `max` coincide, the range will be automatically increased.
/// Also see @axis.inverted.
/// -> auto | array
lim: auto,
/// Whether to invert the limits (swap minimum and maximum). Inverting is
/// applied regardless of whether the limits are set manually or computed automatically.
/// -> bool
inverted: false,
/// Label for the axis. Use a @label object for more options.
/// -> content | lq.label
label: none,
/// The kind of the axis.
/// -> "x" | "y"
kind: "x",
/// Where to place this axis. This can be
/// - one of the sides of the diagram (`top` or `bottom` for $x$-axes,
/// `left` or `right` for $y$-axes),
/// - a `float` coordinate value on the other axis,
/// - a `length` or `relative`,
/// - or a combination of the first and third option through a dictionary
/// with the keys `align` and `offset`.
///
/// More on axis placement can be found in the
/// #link("tutorials/axis#placement-and-mirrors")[axis tutorial].
/// -> auto | alignment | float | relative | dictionary
position: auto,
/// Whether to mirror the axis, i.e., whether to show the axis ticks also on
/// the side opposite of the one specified with @axis.position. When set to
/// `auto`, mirroring is only activated when `position: auto`. More control
/// is granted through a dictionary with the possible keys `ticks` and
/// `tick-labels` to individually activate or deactivate those.
///
/// More on axis mirrors can be found in the
/// #link("tutorials/axis#placement-and-mirrors")[axis tutorial].
/// -> auto | bool | dictionary
mirror: auto,
/// Instead of using the tick locator, specifies the tick locations explicitly
/// and optionally the tick labels. This can be an array with just the tick
/// location or tuples of tick location and label, or a dictionary with the
/// keys `ticks` and `labels`, containing arrays of equal length. When `ticks`
/// is `none`, no ticks are displayed. If it is `auto`, the `tick-locator` is
/// used.
///
/// Check out the #link("tutorials/ticks")[tutorial on ticks] for tips on how
/// to work with ticks.
/// -> auto | array | dictionary | none
ticks: auto,
/// Instead of using the tick locator, specifies the tick positions explicitly
/// and optionally the tick labels.
///
/// Also see the #link("tutorials/ticks")[tutorial on ticks].
/// -> auto | none | int
subticks: auto,
/// Passes the parameter `tick-distance` to the tick locator. The linear tick
/// locator respects this setting and sets the distance between consecutive
/// ticks accordingly. If `tick-args` already contains an entry `tick-distance`,
/// it takes precedence.
/// -> auto | float
tick-distance: auto,
/// Offset for all ticks on this axis. The offset is subtracted from all ticks
/// and shown at the end of the axis (if it is not 0). An offset can be used
/// to avoid overly long tick labels and to focus on the relative distance
/// between data points.
///
/// If `none` or a value of type `content`, the offset is just displayed and
/// has no effect on how the data is presented.
/// -> auto | int | float | content | none
offset: auto,
/// Exponent for all ticks on this axis. All ticks are divided by
/// $10^\mathrm{exponent}$ and the $10^\mathrm{exponent}$ is shown at the end
/// of the axis (if the exponent is not 0). This setting can be used to avoid
/// overly long tick labels.
///
/// In combination with logarithmic tick locators, `none` can be used to
/// force writing out all numbers.
/// -> auto | none | int | "inline"
exponent: auto,
/// Threshold for automatic exponents.
/// -> int
auto-exponent-threshold: 3,
/// The tick locator for the regular ticks.
/// Also see #link("tutorials/ticks#locating-ticks")[locating ticks].
/// -> auto | function
locate-ticks: auto,
/// The formatter for the (major) ticks.
/// Also see #link("tutorials/ticks#formatting-ticks")[formatting ticks].
/// -> auto | function
format-ticks: auto,
/// The tick locator for the subticks.
/// Also see #link("tutorials/ticks#locating-ticks")[locating ticks].
/// -> auto | function
locate-subticks: auto,
/// The formatter for the subticks.
/// Also see #link("tutorials/ticks#displaying-subtick-labels")[displaying subticks].
/// -> auto | none | function
format-subticks: none,
/// An array of extra ticks to display. The ticks can be positions given as `float` data values or @tick instances.
/// -> array
extra-ticks: (),
/// The formatter for the extra ticks.
format-extra-ticks: none,
/// Arguments to pass to the tick locator.
/// -> dictionary
tick-args: (:),
/// Arguments to pass to the subtick locator.
/// -> dictionary
subtick-args: (:),
/// Specifies conversions between the data and the ticks. This can be used to
/// configure a secondary axis to display the same data in a different unit,
/// e.g., the main axis displays the velocity of a particle while the
/// secondary axis displays the associated energy. In this case, one would
/// pick `functions: (x => m*x*x, y => calc.sqrt(y/m))` with some constant
/// `m`. Note that the first function computes the "forward" direction while
/// the second function computes the "backward" direction. The user needs to
/// ensure that the two functions are really inverses of each other.
/// By default, this parameter resolves to the identity.
/// -> auto | array
functions: auto,
/// If set to `true`, the entire axis is hidden.
/// -> bool
hidden: false,
/// How to stroke the spine of the axis. If not `auto`, this is forwarded to
/// @spine.stroke.
/// -> auto | stroke
stroke: auto,
/// Places an arrow tip on the axis spine. This expects a mark as specified by
/// the #link("https://typst.app/universe/package/tiptoe")[tiptoe package].
/// If not `auto`, this is forwarded to @spine.tip.
/// -> auto | none | tiptoe.mark
tip: auto,
/// Places an arrow tail on the axis spine. This expects a mark as specified by
/// the #link("https://typst.app/universe/package/tiptoe")[tiptoe package].
/// If not `auto`, this is forwarded to @spine.toe.
/// -> auto | none | tiptoe.mark
toe: auto,
filter: (value, distance) => true,
/// Plot objects to associate with this axis. This only applies when this is
/// a secondary axis. Automatic limits are then computed according to this
/// axis and transformations of the data coordinates linked to the scaling of
/// this axis.
/// -> any
..plots
) = {
assertations.assert-no-named(plots)
plots = plots.pos()
if "tick-distance" not in tick-args {
tick-args.tick-distance = tick-distance
}
if scale == auto {
scale = "linear"
for plot in plots {
if "axis-id" in plot { plot = plot.plot }
if "datetime" in plot and plot.datetime.at(kind, default: false) {
scale = "datetime"
break
}
}
}
if type(scale) == str {
assert(scale in lqscale.scales, message: "Unknown scale " + scale)
scale = lqscale.scales.at(scale)
}
assert(kind in ("x", "y"), message: "The `kind` of an axis can only be `x` or `y`")
let orthogonal-offset = 0pt
if position == auto {
if kind == "x" { position = bottom }
else if kind == "y" { position = left }
} else if type(position) in (int, float, length, relative, ratio) {
if kind == "x" {
orthogonal-offset = position
position = bottom
}
else if kind == "y" {
orthogonal-offset = position
position = left
}
if mirror == auto { mirror = none }
} else if type(position) == dictionary {
assertations.assert-dict-keys(position, mandatory: ("align", "offset"))
if kind == "x" { orthogonal-offset = position.offset }
else if kind == "y" { orthogonal-offset = position.offset }
position = position.align
if mirror == auto { mirror = none }
if kind == "x" {
assert(position in (top, bottom), message: "For x-axes, `position` can only be \"top\" or \"bottom\", got " + repr(position))
}
if kind == "y" {
assert(position in (left, right), message: "For y-axes, `position` can only be \"left\" or \"right\", got " + repr(position))
}
} else if type(position) == alignment {
if mirror == auto { mirror = none }
if kind == "x" {
assert(position in (top, bottom), message: "For x-axes, `position` can only be \"top\" or \"bottom\", got " + repr(position))
}
if kind == "y" {
assert(position in (left, right), message: "For y-axes, `position` can only be \"left\" or \"right\", got " + repr(position))
}
} else {}
if ticks != auto {
if type(ticks) == dictionary {
assert("ticks" in ticks, message: "When passing a dictionary for `ticks`, you need to provide the keys \"ticks\" and optionally \"labels\"")
locate-ticks = tick-locate.manual.with(ticks: ticks.ticks.zip(ticks.labels))
if "labels" in ticks {
format-ticks = tick-locate.format-ticks-manual
}
} else if type(ticks) == array {
if ticks.len() > 0 and type(ticks.first()) == array {
// let (ticks, labels) = array.zip(..ticks)
locate-ticks = tick-locate.manual.with(ticks: ticks)
format-ticks = tick-format.manual
} else {
locate-ticks = tick-locate.manual.with(ticks: ticks)
}
} else if ticks == none {
locate-ticks = none
format-ticks = none
} else { assert(false, message: "The parameter `ticks` may either be an array or a dictionary")}
}
if locate-ticks == auto {
locate-ticks = if-none(scale.locate-ticks, tick-locate.linear)
}
if format-ticks == auto {
format-ticks = match(
scale.name,
"linear", () => tick-format.linear,
"log", () => tick-format.log.with(base: scale.base),
"symlog", () => tick-format.symlog.with(base: scale.base, threshold: scale.threshold, linscale: scale.linscale),
"datetime", () => tick-format.datetime,
default: () => tick-format.naive
)
}
if subticks == none {
locate-subticks = none
} else if type(subticks) == int {
locate-subticks = tick-locate.subticks-linear.with(num: subticks)
} else if subticks != auto {
assert(false, message: "Unsupported argument type `" + str(type(subticks)) + "` for parameter `subticks`")
}
if locate-subticks == auto {
locate-subticks = if-none(scale.locate-subticks, none)
}
let is-independant = plots.len() > 0
if functions == auto { functions = (x => x, x => x) }
else {
assert(type(functions) == array and functions.map(type) == (function, function), message: "The parameter `functions` for `axis()` expects an array of two functions, a forward and an inverse function.")
assert(plots.len() == 0, message: "An `axis` can either be created with `functions` or with `..plots` but not both. ")
assert(lim == auto, message: "A dependent `axis` with `functions` is not allowed to have manual axis limits. ")
}
if type(lim) == array {
assert.eq(lim.len(), 2, message: "Limit arrays must contain exactly two items")
lim = lim.map(
lim => if type(lim) == datetime {
time.to-seconds(lim).first()
} else {
lim
}
)
} else if lim == auto {
lim = (auto, auto)
} else {
assert(false, message: "Unsupported limit specification")
}
if mirror == auto {
mirror = (ticks: true)
} else if type(mirror) == bool {
if mirror { mirror = (ticks: true) }
else { mirror = none }
} else if type(mirror) == dictionary {
for key in mirror.keys() {
assert(key in ("ticks", "tick-labels"), message: "When passing a dictionary to `axis.mirror`, only the keys \"ticks\" and \"tick-labels\" are valid, got \"" + key + "\"")
}
}
(
type: "axis",
scale: scale,
lim: lim,
functions: (forward: functions.at(0), inv: functions.at(1)),
label: label,
stroke: stroke,
kind: kind,
position: position,
orthogonal-offset: orthogonal-offset,
mirror: mirror,
locate-ticks: locate-ticks,
format-ticks: format-ticks,
locate-subticks: locate-subticks,
format-subticks: format-subticks,
extra-ticks: extra-ticks,
format-extra-ticks: format-extra-ticks,
filter: filter,
tick-args: tick-args,
subtick-args: subtick-args,
offset: offset,
exponent: exponent,
auto-exponent-threshold: auto-exponent-threshold,
plots: plots,
hidden: hidden,
tip: tip,
toe: toe,
inverted: inverted
)
}
#let xaxis = axis.with(kind: "x")
#let yaxis = axis.with(kind: "y")
/// Computes the axis limits of one axis (x or y). For each plot, `xlimits()`
/// and `ylimits()` is called. Plots whose limit functions return `none`
/// will be ignored.
/// If there are no plots or all calls to `xlimits()` and
/// `ylimits()` return `none`, the limits are set to `(0,0)`.
///
/// Regardless of this, a zero-width range is enlarged by calling
/// `next(min, -1)` and `next(max, 1)`.
/// Finally lower and upper margins are applied.
///
#let _axis-compute-limits(
axis,
lower-margin: 0%, upper-margin: 0%,
default-lim: (0, 1),
is-independant: auto
) = {
if is-independant == auto {
is-independant = axis.plots.len() > 0
}
let axis-type = match(axis.kind, "x", "x", "y", "y")
let (x0, x1) = (none, none)
let (tight0, tight1) = (true, true)
if axis.lim.at(0) != auto { x0 = axis.lim.at(0); tight0 = true }
if axis.lim.at(1) != auto { x1 = axis.lim.at(1); tight1 = true }
if auto in axis.lim {
if is-independant {
let plot-limits = axis.plots.map(plot => plot.at(axis-type + "limits")())
.filter(x => x != none)
if plot-limits.len() == 0 {
(x0, x1) = (0, 1)
if axis.scale.identity != 0 {
(x0, x1) = (axis.scale.identity,) * 2
}
} else {
for (plot-x0, plot-x1) in plot-limits {
let tight-bound = (false, false)
if type(plot-x0) == fraction { plot-x0 /= 1fr; tight-bound.at(0) = true }
if axis.lim.at(0) == auto and plot-x0 != none and (x0 == none or plot-x0 < x0) {
x0 = plot-x0
tight0 = tight-bound.at(0)
}
if type(plot-x1) == fraction { plot-x1 /= 1fr; tight-bound.at(1) = true }
if axis.lim.at(1) == auto and plot-x0 != none and (x1 == none or plot-x1 > x1) {
x1 = plot-x1
tight1 = tight-bound.at(1)
}
}
}
} else {
(x0, x1) = default-lim.map(axis.functions.forward)
}
}
if x0 == x1 {
x0 = (axis.scale.inverse)((axis.scale.transform)(x0) - 1)
x1 = (axis.scale.inverse)((axis.scale.transform)(x1) + 1)
}
if axis.inverted {
(x0, x1) = (x1, x0)
}
let k0 = (axis.scale.transform)(x0)
let k1 = (axis.scale.transform)(x1)
let D = k1 - k0
if not tight0 {
x0 = (axis.scale.inverse)(k0 - D * lower-margin/100%)
}
if not tight1 {
x1 = (axis.scale.inverse)(k1 + D * upper-margin/100%)
}
return (x0, x1)
}
/// Generates all ticks and subticks as well as their labels for an axis.
///
/// -> dictionary
#let _axis-generate-ticks(
/// The axis object.
/// -> lq.axis
axis,
/// The length with which the axis will be displayed. This is for example used to determine automatic tick distances.
/// -> length
length: 3cm
) = {
let ticks = ()
let tick-labels
let subticks = ()
let subtick-labels
let (exp, offset) = (axis.exponent, axis.offset)
let em = measure(line(length: 1em, angle: 0deg)).width
axis.tick-args.num-ticks-suggestion = match(
axis.kind,
"x", length / (3.3 * em),
"y", length / (2 * em)
)
let (x0, x1) = axis.lim
if x1 < x0 {
// (x1, x0) = (x0, x1)
} else if x0 == x1 {
assert(
false,
message: "Cannot generate ticks for empty range [" + str(x0) +", " + str(x1) + "]"
)
}
if axis.locate-ticks != none {
let tick-result = (axis.locate-ticks)(x0, x1, ..axis.tick-args)
ticks = tick-result.ticks
let format-result = if axis.format-ticks != none {
(axis.format-ticks)(
tick-result.ticks,
tick-info: tick-result,
exponent: axis.exponent,
offset: if type(offset) in (int, float, auto) { offset } else { 0 },
auto-exponent-threshold: axis.auto-exponent-threshold,
min: x0,
max: x1
)
}
if type(format-result) == array {
tick-labels = format-result
} else if type(format-result) == dictionary {
assertations.assert-dict-keys(
format-result,
mandatory: ("labels",),
optional: ("offset", "exponent")
)
tick-labels = format-result.labels
if "exponent" in format-result {
exp = format-result.exponent
}
if "offset" in format-result and offset == auto {
offset = format-result.offset
}
} else if format-result != none {
assert(
false,
message: "The tick formatter must either return an array of labels or a dictionary with the keys \"labels\", \"exponent\", and \"offset\". Found " + repr(format-result)
)
}
if exp == auto { exp = 0 }
if offset == auto { offset = 0 }
if axis.locate-subticks != none {
let subtick-result = (axis.locate-subticks)(x0, x1, ..tick-result, ..axis.subtick-args)
subticks = subtick-result.ticks
subtick-labels = if axis.format-subticks != none {
(axis.format-subticks)(
subticks,
tick-info: subtick-result,
exponent: axis.exponent,
offset: axis.offset
)
}
if type(subtick-labels) == dictionary {
subtick-labels = subtick-labels.labels
}
}
}
return (
ticks: ticks,
tick-labels: tick-labels,
subticks: subticks,
subtick-labels: subtick-labels,
exp: exp,
offset: offset
)
}
// Draws an axis and its mirror (if any)
#let draw-axis(
axis,
tick-info,
orthogonal-axis-transform: none,
e-get: none
) = {
if axis.hidden { return (none, ()) }
let (ticks, tick-labels, subticks, subtick-labels, exp, offset) = tick-info
// Places a set of ticks together with labels
let place-ticks(
ticks,
labels,
// Where to place the ticks on the diagram
position,
// Whether to show tick labels
display-tick-labels,
sub: false,
kind: "x",
extra-ticks: ()
) = {
if labels == none { labels = (none,) * ticks.len() }
let align = position.inv()
let pad = e-get(lq-tick).pad
let factor = if sub { 1 - (e-get(lq-tick).shorten-sub / 100%) } else { 1 }
let outset = e-get(lq-tick).outset * factor
let shorten-sub = e-get(lq-tick).shorten-sub
let length = e-get(lq-tick).inset * factor + outset
let angle = if align in (top, bottom) { 90deg } else { 0deg }
let tick-stroke = if-none(
merge-strokes(
e-get(lq-tick).stroke,
axis.stroke, (cap: "butt"),
e-get(spine).stroke
),
0.5pt // can be none when spine.stroke is none
)
let tline = line(length: length, angle: angle, stroke: tick-stroke)
let make-tick
if align == right {
make-tick = (label, loc) => place(dx: -outset, dy: loc, {tline + place(dx: -length - pad, right + horizon, label)})
} else if align == left {
make-tick = (label, loc) => place(dx: -length + outset, dy: loc, {tline + place(dx: length + pad, left + horizon, label)})
} else if align == top {
make-tick = (label, loc) => place(dy: -length + outset, dx: loc, {tline + place(dy: length + pad, top + center, label)});
} else if align == bottom {
make-tick = (label, loc) => place(dy: -outset, dx: loc, {tline + place(dy: -length - pad, bottom + center, label)})
}
let max-value = (axis.transform)(axis.lim.at(if kind == "x" { 1 } else { 0 }))
let lq-tick-label = lq-tick-label.with(sub: sub, kind: kind)
labels = labels.map(label => {
if display-tick-labels {
lq-tick-label(label)
}
})
let content = ticks.zip(labels).map(
((tick, label)) => {
let loc = (axis.transform)(tick)
if (axis.filter)(tick, calc.min(loc, max-value - loc)) {
make-tick(label, loc)
}
}
).join()
for tick in extra-ticks {
let format-ticks = if-none(
axis.format-ticks,
tick-format.linear
)
if type(tick) in (int, float) {
tick = lq-tick(
tick,
label: format-ticks((tick,)).labels.first(),
align: position.inv(),
kind: axis.kind
)
}
let label = e.fields(tick).at("label", default: none)
if label != none { labels.push(label) }
let loc = (axis.transform)(e.fields(tick).value)
let offset = if kind == "x" { (dx: loc) } else { (dy: loc) }
content += place(..offset, {
show: e.set_(lq-tick, align: position.inv(), kind: axis.kind)
show e.selector(lq-tick-label): it => {
if display-tick-labels { it }
}
tick
})
} // end extra-ticks
let max-padding = outset
let max-width = 0pt
let size = measure(lq-tick-label[asd])
if display-tick-labels {
let dimension = if axis.kind == "x" { "height" } else { "width" }
let label-space
(label-space, max-width) = labels
.map(label => {
let measured = measure(label)
(measured.at(dimension), measured.width)
})
.fold(
(0pt, 0pt),
((max-dim, max-w), (next-dim, next-w)) => (calc.max(max-dim, next-dim), calc.max(max-w, next-w)),
)
max-padding += label-space
if label-space > 0pt {
max-padding += pad
}
}
// Prevent ticks from line-wrapping by boxing them in enough space.
if kind == "x" {
content = place(box(width: max-width, content))
} else {
content = place(box(width: max-padding, content))
}
return (content, max-padding.to-absolute())
}
// Draws a single axis (*or* a mirror)
let the-axis(
position: axis.position,
display-ticks: true,
display-tick-labels: true,
display-axis-label: true,
kind: "x"
) = {
let content = none
let space = 0pt
let bounds = ()
// Draw spine
if axis.stroke != none {
// TODO (later): use set rules here
let args = (:)
if axis.tip != auto { args.tip = axis.tip }
if axis.toe != auto { args.toe = axis.toe }
if axis.stroke != auto { args.stroke = axis.stroke }
content += place(spine(kind: axis.kind, ..args))
}
if display-ticks {
let (tick-content, tick-space) = place-ticks(
ticks, tick-labels, position, display-tick-labels, kind: kind, extra-ticks: axis.extra-ticks
)
content += tick-content
let (subtick-content, subtick-space) = place-ticks(
subticks, subtick-labels, position, display-tick-labels,
sub: true, kind: kind
)
space = calc.max(tick-space, subtick-space)
content += subtick-content
}
if display-tick-labels {
let attachment = none
if type(offset) in (int, float) and offset != 0 {
attachment += zero.num(positive-sign: true, offset)
} else if offset not in (0, auto) {
attachment += offset
}
if type(exp) == int and exp != 0 {
attachment += {
show "X": none
zero.num("Xe" + str(exp))
}
}
if attachment != none {
let args
if axis.kind == "x" {
args = (
dx: .5em, dy: 0pt,
alignment: bottom + right,
content-alignment: horizon + left
)
if position == top {
args.dy -= 100%
}
} else if axis.kind == "y" {
args = (
dx: 0pt, dy: -.5em,
alignment: top + left,
content-alignment: center + bottom
)
if position == right {
args.dx += 100%
}
}
let (attachment-content, attachment-bounds) = place-with-bounds(
attachment,
..args
)
content += attachment-content
bounds.push(attachment-bounds)
}
}
if axis.label != none and display-axis-label {
let label = axis.label
if e.eid(label) != e.eid(lq-label) {
let constructor = if kind == "x" { xlabel } else { ylabel }
label = constructor(label)
}
let wrap-label = if position in (top, bottom) {
box.with(width: 100%)
} else if position in (left, right) {
box.with(height: 100%)
}
let get-settable-field(element, object, field) = {
e.fields(object).at(field, default: e-get(element).at(field))
}
let dx = get-settable-field(lq-label, label, "dx")
let dy = get-settable-field(lq-label, label, "dy")
let pad = get-settable-field(lq-label, label, "pad")
if pad == none {
pad = 0pt
} else {
pad = pad.to-absolute() + space
}
let body = wrap-label(label)
let size = measure(body)
let (label-content, _) = place-with-bounds(
body, alignment: position, dx: dx, dy: dy, pad: pad
)
content += label-content
if kind == "x" {
space = calc.max(space, size.height + pad)
} else {
space = calc.max(space, size.width + pad)
}
}
// define box of axis spine
content += block(
..if kind == "y" { (height: 100%, width: 0%) }
else { (width: 100%, height: 0pt) },
inset: 0pt, outset: 0pt
)
let main-bounds = create-bounds()
let inset = e-get(lq-tick).inset
if axis.kind == "x" {
main-bounds.right = 100%
if position == top {
main-bounds.top = -space
main-bounds.bottom = inset
} else if position == bottom {
main-bounds.bottom = 100% + space
main-bounds.top = 100% - inset
}
} else {
main-bounds.bottom = 100%
if position == left {
main-bounds.left = -space
main-bounds.right = inset
} else if position == right {
main-bounds.right = 100% + space
main-bounds.left = 100% - inset
}
}
bounds.push(main-bounds)
return (content, bounds)
}
let orthogonal-offset = axis.orthogonal-offset
if type(orthogonal-offset) in (int, float) {
orthogonal-offset = orthogonal-axis-transform(orthogonal-offset)
if axis.position in (bottom, right) {
orthogonal-offset -= 100%
}
}
if axis.kind == "x" {
orthogonal-offset = (0pt, orthogonal-offset)
} else {
orthogonal-offset = (orthogonal-offset, 0pt)
}
let (axis-content, bounds) = the-axis(kind: axis.kind)
let content = place(
axis.position,
axis-content,
dx: orthogonal-offset.at(0),
dy: orthogonal-offset.at(1)
)
if axis.mirror != none {
let (mirror-axis-content, mirror-axis-bounds) = the-axis(
kind: axis.kind,
position: axis.position.inv(),
display-ticks: axis.mirror.at("ticks", default: false),
display-tick-labels: axis.mirror.at("tick-labels", default: false),
display-axis-label: axis.mirror.at("label", default: false),
)
content += place(axis.position.inv(), mirror-axis-content)
bounds += mirror-axis-bounds
}
return (content, bounds.map(b => offset-bounds(b, orthogonal-offset)))
}

View File

@@ -0,0 +1,123 @@
#import "diagram.typ": diagram
#import "../plot/rect.typ": rect
#import "../typing.typ": set-diagram
/// Creates a visual representation of the color mapping used in a plot
/// instance like @scatter or @colormesh.
///
/// This generates a new (usually slim) diagram with a filled gradient
/// according to the color map used in the plot, appropriate ticks, and
/// optionally a label. This diagram can be configured through general `set`
/// rules on @diagram and through additional arguments passed through
/// @colorbar.args.
///
/// ```example
/// #show: lq.set-diagram(height: 3.5cm, width: 4cm)
///
/// #let mesh = lq.colormesh(
/// lq.linspace(-0.3, 1.3),
/// lq.linspace(-0.3, 1.3),
/// (x, y) => x * y,
/// map: gradient.linear(..color.map.icefire).sharp(9)
/// )
///
/// #lq.diagram(
/// mesh
/// )
/// #lq.colorbar(mesh, thickness: 2mm)
/// ```
///
/// The color bar is a separate inline object and can be placed anywhere in the
/// document. Note that if the example above were in code mode, Typst would
/// place the color bar directly after the diagram with no space in-between.
/// You can insert a manual space through `h(.5em)` (or similar).
///
/// Also, the color bar has a fixed length by default and does not know about
/// the dimensions of the previous diagram. In order to guarentee the same
/// height, it is advisable to use a set rule on `diagram` (as demonstrated
/// above) to set the height for both the diagram and the color bar at once.
#let colorbar(
/// A plot instance that uses color-coding, e.g., @scatter, @colormesh, @contour, and @quiver.
/// -> plot
plot,
/// How to orient the colorbar.
/// -> "vertical" | "horizontal"
orientation: "vertical",
/// The thickness of the colorbar.
/// -> length
thickness: 3mm,
/// A label to place on the axis.
/// -> content
label: none,
/// Additional arguments to pass to @diagram.
/// -> any
..args
) = {
let cinfo = plot.cinfo
let grad = if orientation == "vertical" {
rect(
0%, cinfo.min,
width: 100%,
height: cinfo.max - cinfo.min,
fill: gradient.linear(
..cinfo.colormap.stops(),
angle: 90deg
)
)
} else if orientation == "horizontal" {
rect(
cinfo.min, 0%,
height: 100%,
width: cinfo.max - cinfo.min,
fill: gradient.linear(
..cinfo.colormap.stops(),
angle: 0deg
)
)
}
show: set-diagram(
grid: none,
margin: 0%,
)
let preset-args = (:)
if orientation == "vertical" {
preset-args = (
width: thickness,
xaxis: (ticks: none),
yaxis: (position: right, mirror: (:)),
yscale: cinfo.norm,
ylabel: label
)
} else if orientation == "horizontal" {
preset-args = (
height: thickness,
yaxis: (ticks: none),
xaxis: (position: bottom, mirror: (:)),
xscale: cinfo.norm,
xlabel: label
)
} else {
assert(false, message: "Unexpected orientation \"" + orientation + "\", possible values are \"horizontal\" and \"vertical\"")
}
diagram(
..preset-args,
..args,
grad
)
}

View File

@@ -0,0 +1,696 @@
#import "../assertations.typ"
#import "../utility.typ": if-auto
#import "../bounds.typ": update-bounds, place-with-bounds
#import "../process-styles.typ": update-stroke, process-margin, process-grid-arg, twod-ify-alignment
#import "../logic/process-coordinates.typ": transform-point
#import "legend.typ": legend as lq-legend, _place-legend-with-bounds
#import "grid.typ": grid as lq-grid
#import "title.typ": title as lq-title, _place-title-with-bounds
#import "label.typ": label as lq-label
#import "axis.typ": axis as lq-axis, draw-axis, _axis-compute-limits, _axis-generate-ticks
#import "../logic/transform.typ": create-trafo
#import "../style/styling.typ": init as cycle-init, style, process-cycles-arg
#import "../style/map.typ": petroff10
#import "@preview/elembic:1.1.1" as e
#let debug = false
/// Creates a new diagram.
///
/// -> lq.diagram
#let diagram(
/// The width of the diagram. This can be
/// - A `length`; in this case, it defines just the width of the data area,
/// excluding axes, labels, title etc.
/// - A `ratio` or `relative` where the ratio part is relative to the width
/// of the parent that the diagram is placed in. This is not allowed if the
/// parent has an unbounded width, e.g., a page with `width: auto`.
/// -> length | relative
width: 6cm,
/// The height of the diagram. This can be
/// - A `length`; in this case, it defines just the height of the data area,
/// excluding axes, labels, title etc.
/// - A `ratio` or `relative` where the ratio part is relative to the height
/// of the parent that the diagram is placed in. This is not allowed if the
/// parent has an unbounded height, e.g., a page with `height: auto`.
/// -> length | relative
height: 4cm,
/// The title for the diagram. Use a @title object for more options.
/// -> lq.title | str | content | none
title: none,
/// Options to pass to the @legend constructor. If set to `none`, no legend is
/// shown.
///
/// Alternatively, a legend with entirely custom entries can be created and
/// given here.
/// -> none | dictionary | lq.legend
legend: (:),
/// Data limits along the $x$-axis. Expects `auto` or a tuple `(min, max)`
/// where `min` and `max` may individually be `auto`. Also see @axis.lim.
/// -> auto | array
xlim: auto,
/// Data limits along the $y$-axis. Expects `auto` or a tuple `(min, max)`
/// where `min` and `max` may individually be `auto`. Also see @axis.lim.
/// -> auto | array
ylim: auto,
/// Label for the $x$-axis. Use a @label object for more options.
/// -> lq.label | content
xlabel: none,
/// Label for the $y$-axis. Use a @label object for more options.
/// -> lq.label | content
ylabel: none,
/// Options to apply to the grid. A `stroke`, `color`, or `length` argument
/// directly sets the grid stroke while a `dictionary` with the possible keys
/// `stroke`, `stroke-sub`, and `z-index` gives more fine-grained control.
/// Setting this parameter to `none` removes the grid entirely.
/// See @grid for more details.
/// -> auto | none | dictionary | stroke | color | length
grid: auto,
/// Sets the scale of the $x$-axis. This may be a @scale object or the name
/// of one of the built-in scales `"linear"`, `"log"`, `"symlog"`, and
/// `"datetime"`.
///
/// If left at `auto`, the scale will be set to `"datetime"` if any of the
/// plots uses datetime coordinates and `"linear"` otherwise.
/// -> auto | str | lq.scale
xscale: auto,
/// Sets the scale of the $y$-axis. This may be a @scale object or the name
/// of one of the built-in scales `"linear"`, `"log"`, `"symlog"`, and
/// `"datetime"`.
///
/// If left at `auto`, the scale will be set to `"datetime"` if any of the
/// plots uses datetime coordinates and `"linear"` otherwise.
/// -> auto | str | lq.scale
yscale: auto,
/// Configures the $x$-axis through a dictionary of arguments to pass to the
/// constructor of the axis. See @axis for available options.
/// -> none | dictionary
xaxis: (:),
/// Configures the $y$-axis through a dictionary of arguments to pass to the
/// constructor of the axis. See @axis for available options.
/// -> none | dictionary
yaxis: (:),
/// Configures the automatic margins of the diagram around the data. If set
/// to `0%`, the outer-most data points align tightly with the edges of the
/// diagram (as long as the axis limits are left at `auto`). Otherwise, the
/// margins are computed in percent of the covered range along an axis (in
/// scaled coordinates).
///
/// The margins can be set individually for each side by passing a dictionary
/// with the possible keys
/// - `left`, `right`, `top`, `bottom` for addressing individual sides,
/// - `x`, `y` for left/right and top/bottom combined sides, and
/// - `rest` for all sides not specified by any of the above.
///
/// -> ratio | dictionary
margin: 6%,
/// Style cycle to use for this diagram. Check out the
/// #link("tutorials/cycles")[cycles tutorial] for more information.
/// The elements of a cycle array should either be
/// - all functions as described in the tutorial, or
/// - all of type `color` (e.g., one of the maps under `lq.color.map`), or
/// - all of type `dictionary` with possible keys `color`, `stroke`, and
/// `mark`.
///
/// -> array
cycle: petroff10,
/// How to fill the background of the data area.
/// -> none | color | gradient | tiling
fill: none,
/// Plot objects like @plot, @bar, @scatter, @contour etc. and additional
/// @axis objects.
/// -> any
..children
) = {}
#let create-principle-axis(
axis, lim, scale,
kind: "x",
label,
plots,
margin,
it
) = {
if axis == none { axis = (hidden: true) }
if type(axis) == dictionary {
axis = lq-axis(kind: kind, label: label, scale: scale, lim: lim, ..axis, ..plots)
}
let margin = if kind == "x" {
(lower-margin: margin.left, upper-margin: margin.right)
} else {
(lower-margin: margin.bottom, upper-margin: margin.top)
}
axis.lim = _axis-compute-limits(axis, is-independant: true, ..margin)
let normalized-trafo = create-trafo(axis.scale.transform, ..axis.lim)
axis.normalized-transform = normalized-trafo
axis
}
#let fill-in-transforms(axes, width, height) = {
let xaxis = axes.at(0)
let yaxis = axes.at(1)
axes.map(axis => {
let normalized-trafo = if axis.plots.len() > 0 { // is independent axis
create-trafo(axis.scale.transform, ..axis.lim)
} else { // is dependent axis
let model-axis = if axis.kind == "x" { xaxis } else { yaxis }
a => (model-axis.normalized-transform)((axis.functions.inv)(a))
}
axis.transform = if axis.kind == "x" {
x => normalized-trafo(x) * width
} else {
y => (1 - normalized-trafo(y)) * height
}
axis
})
}
#let generate-grid(axis-info, xaxis, yaxis, grid: auto) = {
grid = process-grid-arg(grid)
lq-grid(
axis-info.x.subticks.map(xaxis.transform),
sub: true,
kind: "x",
..grid
)
lq-grid(
axis-info.y.subticks.map(yaxis.transform),
sub: true,
kind: "y",
..grid
)
lq-grid(
axis-info.x.ticks.map(xaxis.transform),
sub: false,
kind: "x",
..grid
)
lq-grid(
axis-info.y.ticks.map(yaxis.transform),
sub: false,
kind: "y",
..grid
)
}
#let generate-legend(legend, legend-entries, e-get) = {
if legend != none and (legend-entries.len() > 0 or e.eid(legend) == e.eid(lq-legend)) {
let (legend-content, legend-bounds) = _place-legend-with-bounds(
legend, legend-entries, e-get
)
(
content: legend-content,
bounds: legend-bounds
)
}
}
#let generate-plots(
plots, cycle, width, height, axes, only-bounds: false
) = {
let (xaxis, yaxis) = axes.slice(0, 2)
let transform(x, y) = (
(xaxis.transform)(x),
(yaxis.transform)(y),
)
cycle = process-cycles-arg(cycle)
let update-bounds = update-bounds.with(width: width, height: height)
let bounds = (left: 0pt, right: width, top: 0pt, bottom: height)
let artists = ()
let legend-entries = ()
let cycle-index = 0
for plot in plots {
let transform = transform
if type(plot) == dictionary and "axis-id" in plot {
let axis = axes.at(plot.axis-id + 2)
transform = if axis.kind == "x" {
(x, y) => ((axis.transform)(x), (yaxis.transform)(y))
} else {
(x, y) => ((xaxis.transform)(x), (axis.transform)(y))
}
plot = plot.plot
}
if plot.at("id", default: none) == "place" and not plot.clip {
let (px, py) = transform-point(plot.x, plot.y, transform)
let (_, place-bounds) = place-with-bounds(
plot.body, dx: px, dy: py,
content-alignment: twod-ify-alignment(plot.align)
)
bounds = update-bounds(bounds, place-bounds)
}
let takes-part-in-cycle = not plot.at("ignores-cycle", default: true)
let cycle-style = cycle.at(calc.rem(cycle-index, cycle.len()))
if not only-bounds {
let plotted-plot = {
show: cycle-init
show: cycle-style
(plot.plot)(plot, transform)
}
if takes-part-in-cycle {
cycle-index += 1
}
if plot.at("clip", default: true) {
plotted-plot = place(
box(width: width, height: height, clip: true, plotted-plot)
)
}
artists.push((content: plotted-plot, z: plot.at("z-index", default: 2)))
}
if "legend" in plot and plot.label != none {
plot.make-legend = true
let legend-trafo(x, y) = {
(x * 100%, (1 - y) * 100%)
}
let handle = {
show: cycle-init
show: cycle-style
(plot.plot)(plot, legend-trafo)
}
legend-entries.push((
box(width: 2em, height: .7em, handle),
plot.label
))
}
}
(
legend-entries: legend-entries,
artists: artists,
bounds: bounds
)
}
#let attempt-layout(
width, height,
it: (:), axes: (), plots: (), e-get: none,
auto-height: true, auto-width: true,
available-size: (0pt, 0pt)
) = {
axes = fill-in-transforms(axes, width, height)
let (xaxis, yaxis) = axes.slice(0, 2)
let get-settable-field(element, object, field) = {
e.fields(object).at(field, default: e-get(element).at(field))
}
let bounds = (left: 0pt, right: width, top: 0pt, bottom: height)
let update-bounds = update-bounds.with(width: width, height: height)
let tickings = axes.map(axis =>
_axis-generate-ticks(
axis,
length: if axis.kind == "x" { width } else { height }
)
)
for (axis, ticking) in axes.zip(tickings) {
let (_, axis-bounds) = draw-axis(
axis, ticking, e-get: e-get, orthogonal-axis-transform: (if axis.kind == "x" { yaxis} else {xaxis}).transform
)
bounds = axis-bounds.fold(bounds, update-bounds)
}
if it.title != none {
let (_, title-bounds) = _place-title-with-bounds(
it.title, get-settable-field, width, height
)
bounds = update-bounds(bounds, title-bounds)
}
let (legend-entries, bounds: plot-bounds) = generate-plots(
plots, it.cycle, width, height,
axes, only-bounds: true
)
bounds = update-bounds(bounds, plot-bounds)
let legend = generate-legend(it.legend, legend-entries, e-get)
if legend != none {
bounds = update-bounds(bounds, legend.bounds)
}
if auto-width {
width = available-size.width - bounds.right + bounds.left + width
}
if auto-height {
height = available-size.height - bounds.bottom + bounds.top + height
}
return (width, height, tickings)
}
#let show-bounds(bounds, clr: red) = {
if not debug { return none }
place(dx: bounds.left, dy: bounds.top, rect(width: bounds.right - bounds.left, height: bounds.bottom - bounds.top, fill: clr))
}
#let draw-diagram(it) = {
set math.equation(numbering: none)
set curve(stroke: .7pt)
set line(stroke: .7pt)
// show: e.show_(
// lq-label.with(kind: "y"),
// it => {
// set rotate(-90deg)
// it
// }
// )
// Elements can be plot objects or plots on an axis: (axis-id: int, plot: dict).
let plots = ()
// solely used for computing limits
let (xplots, yplots) = ((), ())
// all additional axes
let axes = ()
for child in it.children {
if child == none { continue }
if type(child) != dictionary {
panic("Unexpected child `" + repr(child) + "`. Expected a plot or an axis. ")
}
if child.at("type", default: "") == "axis" { // is an axis
axes.push(child)
if child.plots.len() > 0 {
plots += child.plots.map(plot => (axis-id: axes.len() - 1, plot: plot))
if child.kind == "x" {
yplots += child.plots
} else {
xplots += child.plots
}
}
} else { // is a plot
yplots.push(child)
xplots.push(child)
plots.push(child)
}
}
let margin = process-margin(it.margin)
let xaxis = create-principle-axis(
kind: "x",
it.xaxis, it.xlim, it.xscale,
it.xlabel, xplots, margin, it
)
let yaxis = create-principle-axis(
kind: "y",
it.yaxis, it.ylim, it.yscale,
it.ylabel, yplots, margin, it
)
// Compute limits for additional axes
for i in range(axes.len()) {
let axis = axes.at(i)
let axes-margin = if axis.kind == "x" {
(lower-margin: margin.left, upper-margin: margin.right)
} else {
(lower-margin: margin.bottom, upper-margin: margin.top)
}
let model-axis = if axis.kind == "x" { xaxis } else { yaxis }
axes.at(i).lim = _axis-compute-limits(
axis, default-lim: model-axis.lim, ..axes-margin
)
}
// Tell additional axes how to transform their coordinates
e.get(e-get => {
let axes = (xaxis, yaxis) + axes
let get-settable-field(element, object, field) = {
e.fields(object).at(field, default: e-get(element).at(field))
}
let it = it
let tickings = ()
// Diagram may have relative/ratio width or height
if type(it.width) == relative or type(it.height) == relative {
let attempt-layout = attempt-layout.with(
auto-width: type(it.width) != length,
auto-height: type(it.height) != length,
available-size: it.size, it: it, axes: axes, e-get: e-get, plots: plots
)
let exact-or-guess(length, container-length) = {
if type(length) == std.length { length }
else { 0.9 * container-length * length.ratio + length.length.to-absolute() }
}
// First guess for diagram area
let (width, height, ..) = attempt-layout(
exact-or-guess(it.width, it.size.width),
exact-or-guess(it.height, it.size.height),
)
// Now that we have a better guess for the size of the diagram area
// let us re-evaluate the ticking because maybe our initial guess was really bad.
// In this step, we expect the size not too change very substantially,
// so we fix the ticking now and re-use it again in the final layout step.
(it.width, it.height, tickings) = attempt-layout(width, height)
} else {
tickings = axes.map(axis => _axis-generate-ticks(axis, length: if axis.kind == "x" { it.width } else { it.height }))
}
axes = fill-in-transforms(axes, it.width, it.height)
let (xaxis, yaxis) = axes.slice(0, 2)
let bounds = (left: 0pt, right: it.width, top: 0pt, bottom: it.height)
let update-bounds = update-bounds.with(width: it.width, height: it.height)
let diagram = box(
width: it.width, height: it.height,
inset: 0pt, outset: 0pt,
stroke: none, fill: it.fill,
{
set align(top + left) // sometimes alignment is messed up
set place(left) // important for RTL text direction
let artists = ()
// GRID
let axis-info = (
x: tickings.at(0),
y: tickings.at(1),
)
artists.push((
content: generate-grid(axis-info, xaxis, yaxis, grid: it.grid), z: e-get(lq-grid).z-index
))
// PLOTS
let (legend-entries, artists: plot-artists, bounds: plot-bounds) = generate-plots(
plots, it.cycle, it.width, it.height,
axes
)
artists += plot-artists
bounds = update-bounds(bounds, plot-bounds)
// AXES
for (axis, ticking) in axes.zip(tickings) {
let (axis-content, axis-bounds) = draw-axis(
axis, ticking, e-get: e-get,
orthogonal-axis-transform: (if axis.kind == "x" { yaxis} else {xaxis}).transform
)
artists.push((content: axis-content, z: 20))
axis-bounds.map(show-bounds.with(clr: rgb("#2222AA22"))).join()
bounds = axis-bounds.fold(bounds, update-bounds)
}
// TITLE
if it.title != none {
let (title-content, title-bounds) = _place-title-with-bounds(
it.title, get-settable-field, it.width, it.height
)
artists.push((content: title-content, z: 20))
bounds = update-bounds(bounds, title-bounds)
}
// LEGEND
let legend = generate-legend(it.legend, legend-entries, e-get)
if legend != none {
artists.push((content: legend.content, z: e-get(lq-legend).z-index))
bounds = update-bounds(bounds, legend.bounds)
}
artists.sorted(key: artist => artist.z).map(artist => artist.content).join()
})
bounds.bottom -= it.height
bounds.right -= it.width
bounds.left *= -1
bounds.top *= -1
box(
inset: bounds,
diagram,
stroke: if debug { 0.1pt } else { none },
baseline: bounds.bottom
)
})
}
#let folding-dict = e.types.wrap(dictionary, fold: old-fold => (a, b) => a + b)
#let diagram = e.element.declare(
"diagram",
prefix: "lilaq",
display: it => {
if type(it.width) == relative or type(it.height) == relative {
box(layout(size => {
if type(it.width) == relative {
// We have an exception for 0% which is useful to fit the _entire_
// diagram to fixed dimensions.
assert(
size.width != float.inf * 1pt or it.width.ratio == 0%,
message: "Cannot create diagram with relative width (" +
repr(it.width) + ") placed in a container with automatic width"
)
size.width = size.width*it.width.ratio + it.width.length.to-absolute()
}
if type(it.height) == relative {
assert(
size.height != float.inf * 1pt or it.height.ratio == 0%,
message: "Cannot create diagram with relative height (" +
repr(it.height) + ") placed in a container with automatic height"
)
size.height = size.height*it.height.ratio + it.height.length.to-absolute()
}
draw-diagram(it + (size: size))
}))
} else {
draw-diagram(it)
}
},
fields: (
e.field("children", e.types.any, required: true),
e.field("width", e.types.union(length, relative), default: 6cm),
e.field("height", e.types.union(length, relative), default: 4cm),
e.field("title", e.types.union(none, str, content, lq-title), default: none),
e.field("legend", e.types.option(e.types.union(dictionary, lq-legend)), default: (:)),
e.field("xlim", e.types.wrap(e.types.union(auto, array), fold: none), default: auto),
e.field("ylim", e.types.wrap(e.types.union(auto, array), fold: none), default: auto),
e.field("xlabel", e.types.option(e.types.union(lq-label, str, content)), default: none),
e.field("ylabel", e.types.option(e.types.union(lq-label, str, content)), default: none),
e.field("grid", e.types.union(auto, none, dictionary, stroke, color, length), default: auto),
e.field("xscale", e.types.union(auto, str, dictionary), default: auto),
e.field("yscale", e.types.union(auto, str, dictionary), default: auto),
e.field("xaxis", e.types.option(folding-dict), default: (:)),
e.field("yaxis", e.types.option(folding-dict), default: (:)),
e.field("margin", e.types.union(ratio, dictionary), default: 6%),
e.field("cycle", e.types.wrap(e.types.array(e.types.union(function, color, dictionary)), fold: none), default: petroff10),
e.field("fill", e.types.option(e.types.paint), default: none),
),
parse-args: (default-parser, fields: none, typecheck: none) => (args, include-required: false) => {
let args = if include-required {
let values = args.pos()
arguments(values, ..args.named())
} else if args.pos() == () {
args
} else {
return (false, "element 'diagram': unexpected positional arguments\n hint: these can only be passed to the constructor")
}
default-parser(args, include-required: include-required)
},
)

View File

@@ -0,0 +1,94 @@
#import "@preview/elembic:1.1.1" as e
/// An error bar object for a plot. This type allows for quick configuration and
/// complete restyling of error bars. For drawing plots with error bars,
/// use @plot.
///
/// ```example
/// #lq.diagram(
/// lq.plot(
/// (1, 2, 3, 4),
/// (1, 2, 1.5, 2.1),
/// xerr: .2,
/// yerr: (.2, .1, .2, .2),
/// )
/// )
/// ```
///
/// The default styling can be changed through set rules
/// ```example
/// #show: lq.set-errorbar(stroke: 1pt + red, cap: none)
///
/// #lq.diagram(
/// lq.plot(
/// (1, 2, 3, 4),
/// (1, 2, 1.5, 2.1),
/// xerr: .2,
/// yerr: (.2, .1, .2, .2),
/// )
/// )
/// ```
#let errorbar(
/// The kind of error bar: horizontal (`"x"`) or vertical (`"y"`).
/// -> "x" | "y"
kind: "x",
/// The length of the cap. If set to `none`, no cap is drawn.
/// -> none | length
cap: 3pt,
/// How to stroke the error bar. If set to `auto`, the stroke is inherited
/// the plot.
/// -> auto | stroke
stroke: auto,
/// How to stroke the cap. If set to `auto`, the stroke is inherited
/// from @errorbar.stroke.
/// -> auto | stroke
cap-stroke: auto,
) = {}
#let errorbar = e.element.declare(
"errorbar",
prefix: "lilaq",
display: it => {
set curve(stroke: it.stroke) if it.stroke != auto
if it.kind == "x" {
place(horizon, curve(curve.line((100%, 0pt))))
} else {
place(center + horizon, curve(curve.line((0pt, 100%))))
}
if it.cap != none {
set curve(stroke: it.cap-stroke) if it.cap-stroke != auto
if it.kind == "x" {
let cap = curve(curve.line((0pt, it.cap)))
place(left + horizon, cap)
place(right + horizon, cap)
} else {
let cap = curve(curve.line((it.cap, 0pt)))
place(center + top, cap)
place(center + bottom, cap)
}
}
},
labelable: false,
fields: (
e.field("kind", str, default: "x"),
e.field("stroke", e.types.smart(stroke), default: auto),
e.field("cap", e.types.option(length), default: 2.5pt),
e.field("cap-stroke", e.types.smart(stroke), default: auto),
)
)

View File

@@ -0,0 +1,113 @@
#import "@preview/elembic:1.1.1" as e
/// An axis grid for highlighting tick positions in the diagram area. The
/// grid lines are determined from the ticks located by the tick locators
/// of the main $x$ and $y$ axes for vertical and horizontal grid lines,
/// respectively.
///
/// One way to set up the grid stroke is with a style rule. The stroke
/// for the grid lines at the main ticks is controlled by @grid.stroke and the stroke of the subticks by @grid.stroke-sub:
/// ```example
/// #show: lq.set-grid(
/// stroke: teal,
/// stroke-sub: 0.5pt + luma(90%)
/// )
///
/// #lq.diagram(
///
/// )
/// ```
///
/// Through the parameter @diagram.grid, the look of the grid can also be
/// controlled directly for an individual diagram.
/// ```example
/// #lq.diagram(
/// grid: (stroke: black, stroke-sub: 0.25pt)
/// )
/// ```
/// Here you can also pass `none` to deactivate the grid entirely (equivalent
/// to `#show: lq.set-grid(stroke: none)`).
///
/// In order to address the $x$ and $y$ grid individually, use `cond-set`
/// ```example
/// #show: lq.cond-set(lq.grid.with(kind: "x"), stroke: orange)
///
/// #lq.diagram(
/// width: 4.5cm, height: 3cm
/// )
/// ```
#let grid(
/// A list of tick positions as absolute length coordinates within the
/// diagram frame. This is automatically filled by @diagram with the ticks
/// resulting from the axes' tick locators.
/// -> array
ticks,
/// Whether the ticks passed to @grid.ticks are subticks.
/// -> bool
sub,
/// The axis kind: horizontal (`"x"`) or vertical (`"y"`).
/// -> "x" | "y"
kind: "x",
/// How to stroke grid lines.
/// -> none | stroke
stroke: 0.5pt + luma(80%),
/// How to stroke grid lines for subticks. If `auto`, the style is inherited
/// from @grid.stroke.
/// -> auto | none | stroke
stroke-sub: none,
/// Determines the $z$ position of the grid in the order of rendered diagram
/// objects. See @plot.z-index.
/// -> int | float
z-index: 0,
) = {}
// A stroke type that can also be auto or none and that still
// folds correctly. During folding auto and none override an
// existing stroke fully.
#let auto-none-stroke = e.types.wrap(
e.types.smart(e.types.option(stroke)),
fold: old-fold => (outer, inner) => if inner in (none, auto) or outer in (none, auto) { inner } else { (e.types.native.stroke_.fold)(outer, inner) }
)
#let grid = e.element.declare(
"grid",
prefix: "lilaq",
display: it => {
let stroke = if it.sub { it.stroke-sub } else { it.stroke }
if stroke == none { return }
let line
if it.kind == "x" {
line = tick => place(
std.line(start: (tick, 0%), end: (tick, 100%), stroke: stroke)
)
} else if it.kind == "y" {
line = tick => place(
std.line(start: (0%, tick), end: (100%, tick), stroke: stroke)
)
}
it.ticks.map(line).join()
},
fields: (
e.field("ticks", array, required: true),
e.field("sub", bool, default: false),
e.field("kind", str, default: "x"),
e.field("stroke", e.types.option(stroke), default: 0.5pt + luma(80%)),
e.field("stroke-sub", auto-none-stroke, default: none),
e.field("z-index", float, default: 0),
),
)

View File

@@ -0,0 +1,79 @@
/// A label for a diagram axis.
#let label(
/// Content to show in the label.
/// -> any
body,
/// The kind of axis that the label is attached to.
/// -> "x" | "y"
kind: "x",
/// Horizontal offset.
/// -> length
dx: 0pt,
/// Vertical offset.
/// -> length
dy: 0pt,
/// Padding between the axis (and its ticks and labels) and the label.
/// When this is set to `none`, the label is drawn directly on the axis
/// (ignoring the ticks).
/// -> none | length
pad: 0.75em,
/// Angle at which the label is drawn. The label of a `y` axis is often
/// drawn at `-90deg`.
/// -> angle
angle: 0deg
) = {
(
body: body,
dx: dx,
dy: dy,
pad: pad,
angle: angle
)
}
#import "@preview/elembic:1.1.1" as e
#let label = e.element.declare(
"label",
prefix: "lilaq",
display: it => {
let angle = it.angle
if it.angle == auto {
angle = if it.kind == "y" { -90deg } else { 0deg }
}
rotate(angle, it.body, reflow: true)
},
fields: (
e.field("body", e.types.option(content), required: true),
e.field("dx", relative, default: 0pt),
e.field("dy", relative, default: 0pt),
e.field(
"kind",
e.types.union(
e.types.literal("x"),
e.types.literal("y")
),
default: "x"
),
e.field("pad", e.types.option(length), default: .7em),
e.field("angle", e.types.smart(angle), default: auto),
)
)
#let xlabel = label.with(kind: "x")
#let ylabel = label.with(kind: "y")

View File

@@ -0,0 +1,172 @@
#import "../process-styles.typ": twod-ify-alignment
#import "../bounds.typ": place-with-bounds
#import "../assertations.typ"
#import "@preview/elembic:1.1.1" as e
/// A diagram legend listing all labeled plots.
///
/// ```example
/// #lq.diagram(
/// legend: (position: top + left),
///
/// lq.plot((1,2,3), (1,2,3), label: [Data A]),
/// lq.plot((1,2,3), (2,3,4), label: [Data B]),
/// )
/// ```
///
/// Also refer to the
/// #link("tutorials/legend")[legend tutorial] for more details.
#let legend(
/// The items to place in the legend. This field is
/// filled automatically by @diagram.
/// -> array
..children,
/// How to fill the background of the legend.
/// -> none | color | gradient | tiling
fill: white.transparentize(20%),
/// Determines the padding of the entire legend within its box.
/// -> relative
inset: 0.3em,
/// How to stroke the outer border of the legend.
/// -> none | stroke
stroke: 0.5pt + gray,
/// The radius of the outer border of the legend.
/// -> relative | dictionary
radius: 1.5pt,
/// Where to place the legend in the diagram. This can be an alignment
/// or a position `(x, y)` where `x` and `y` are relative lengths, i.e.,
/// they can be
/// - lengths like `20pt` or `2em`,
/// - ratios like `50%` (measuring in the data area),
/// - or a combination thereof.
///
/// To place the legend outside the diagram, say to the right, use a
/// combination of `position: left + horizon` and `dx: 100%`. Positioning
/// of the legend is treated more in-depth in the
/// #link("tutorials/legend#positioning")[legend tutorial].
/// -> alignment | array
position: top + right,
/// In the case that @legend.position is an `alignment`, `pad` determines
/// how much to pad the legend from the outer edge of the data area
/// of the diagram.
/// -> length
pad: 2pt,
/// The horizontal displacement of the legend from the position specified
/// through @legend.position.
/// -> relative
dx: 0pt,
/// The vertical displacement of the legend from the position specified
/// through @legend.position.
/// -> relative
dy: 0pt,
/// Specifies the $z$ position of the legend in the order of rendered
/// diagram objects.
/// -> int | float
z-index: 25,
) = {}
#let legend = e.element.declare(
"legend",
prefix: "lilaq",
display: it => box(
stroke: it.stroke,
inset: it.inset,
fill: it.fill,
radius: it.radius,
grid(..it.children)
),
fields: (
e.field("children", e.types.array(e.types.any), required: true),
e.field("fill", e.types.option(e.types.paint), default: white.transparentize(20%)),
e.field("inset", relative, default: 0.3em),
e.field("stroke", e.types.option(stroke), default: 0.5pt + gray),
e.field("radius", e.types.union(relative, dictionary), default: 1.5pt),
e.field("position", e.types.union(alignment, array), default: top + right),
e.field("pad", length, default: 2pt),
e.field("dx", relative, default: 0pt),
e.field("dy", relative, default: 0pt),
e.field("z-index", float, default: 6),
),
parse-args: (default-parser, fields: none, typecheck: none) => (args, include-required: false) => {
let args = if include-required {
let values = args.pos()
arguments(values, ..args.named())
} else if args.pos() == () {
args
} else {
return(false, "element 'legend': unexpected positional arguments\n hint: these can only be passed to the constructor")
}
default-parser(args, include-required: include-required)
},
)
#let _place-legend-with-bounds(
/// -> none | dictionary | legend
my-legend,
/// -> array(array)
legend-entries,
e-get
) = {
let get-settable-field(element, object, field) = {
e.fields(object).at(field, default: e-get(element).at(field))
}
if e.eid(my-legend) != e.eid(legend) {
my-legend = legend(..legend-entries.join(), ..my-legend)
}
let pos = get-settable-field(legend, my-legend, "position")
let dx = get-settable-field(legend, my-legend, "dx")
let dy = get-settable-field(legend, my-legend, "dy")
let pad = get-settable-field(legend, my-legend, "pad")
let alignment = top + left
let content-alignment = "inside"
if type(pos) == std.alignment {
alignment = pos
} else if type(pos) == array {
assert.eq(pos.len(), 2, message: "`legend.position` needs to be a pair of coordinates, got " + repr(pos))
dx += pos.at(0)
dy += pos.at(1)
pad = 0pt
}
place-with-bounds(
alignment: alignment,
content-alignment: content-alignment,
dx: dx, dy: dy, pad: pad,
wrap-in-box: true,
{
set grid(
columns: 2,
stroke: none,
inset: 2pt,
align: horizon + start
)
set grid.cell(breakable: false)
my-legend
}
)
}

View File

@@ -0,0 +1,190 @@
#import "@preview/tiptoe:0.3.1": arc
/// A mark for a plot. Refer to the #link("tutorials/marks")[mark tutorial] for
/// a list of available mark shapes and more details.
#let mark(
/// The size of the mark. The built-in mark shapes are tuned to match in
/// optical size, see #link("tutorials/marks#sizing")[mark sizing].
/// -> length
size: 4pt,
/// How to fill the mark. If set to `auto`, the fill is inherited from the
/// plot.
/// -> auto | none | color | gradient | tiling
fill: auto,
/// How to stroke the mark. If set to `auto`, the stroke is inherited from the
/// plot.
/// -> stroke
stroke: 0.7pt,
/// The shape of the mark. This can be a string identifying one of the
/// built-in marks (check out the tutorial) or a function that takes a mark
/// and produces content.
/// -> str | function
shape: "."
) = {}
#let mark = grid
#let circle = mark => {
let radius = mark.size / 2
move(
dx: -radius,
dy: -radius,
std.ellipse(width: radius*2, height: radius*2, fill: mark.fill, stroke: mark.stroke)
)
}
#let small-circle = mark => {
let radius = mark.size / 4
move(
dx: -radius,
dy: -radius,
std.ellipse(width: radius*2, height: radius*2, fill: mark.fill, stroke: mark.stroke)
)
}
#let point = mark => {
let radius = .5pt
move(
dx: -radius,
dy: -radius,
std.circle(radius: radius, fill: mark.fill, stroke: mark.stroke)
)
}
#let square = mark => {
let s = mark.size * 0.85
move(
dx: -s / 2,
dy: -s / 2,
rect(width: s, height: s, fill: mark.fill, stroke: mark.stroke)
)
}
#let cross = mark => {
let s = mark.size / calc.sqrt(8) * 1.2
place(line(start: (-s, -s), end: (s, s), stroke: mark.stroke))
line(start: (s, -s), end: (-s, s), stroke: mark.stroke)
}
// #let plus = mark => {
// let s = mark.size / 2 * 1.2
// place(line(start: (0pt, -s), end: (0pt, s), stroke: mark.stroke))
// line(start: (s, 0pt), end: (-s, 0pt), stroke: mark.stroke)
// }
#let polygon = (mark, n: 5, angle: 0deg) => {
// The last term serves for equalizing the apparent size of polygons with different n.
let radius = mark.size / 2 * calc.sqrt(1 + 4 / (n*n))
let dy = (0, .13, 0, .03, 0, .02).at(n - 2, default: 0) * radius
let poly = std.polygon(
stroke: mark.stroke, fill: mark.fill,
..range(n).map(i => {
let phi = i * 360deg / n
(
radius * calc.sin(phi),
-radius * calc.cos(phi) + dy
)
})
)
if angle != 0deg {
poly = rotate(angle, origin: left + top, poly)
}
poly
}
#let star = (mark, n: 5, angle: 0deg, inset: 60%) => {
let radius = mark.size / 2 * 1.15
std.polygon(
stroke: mark.stroke,
fill: mark.fill,
..range(n * 2).map(i => {
let r = if calc.even(i) { radius } else { radius * (100% - inset) }
let phi = i * 360deg / n / 2 + angle
(
r * calc.sin(phi),
-r * calc.cos(phi)
)
})
)
}
#let asterisk = star.with(inset: 100%)
#let moon = (mark, angle: 0deg) => {
let radius = mark.size / 2
arc(
radius: radius,
fill: mark.fill,
closed: "segment",
arc: 180deg,
stroke: 0pt,
angle: angle
)
move(
dx: -radius,
dy: -radius,
std.circle(radius: radius, fill: none, stroke: mark.stroke)
)
}
#let text-mark = (mark, body: emoji.heart) => place(
center + horizon,
body
)
#let marks = (
".": small-circle,
",": point,
"*": asterisk,
"asterisk": asterisk,
"x": cross,
"+": asterisk.with(n: 4),
"|": polygon.with(n: 2),
"-": polygon.with(n: 2, angle: 90deg),
"a3": asterisk.with(n: 3),
"a4": asterisk.with(n: 4),
"a5": asterisk.with(n: 5),
"a6": asterisk.with(n: 6),
"<": polygon.with(n: 3, angle: -90deg),
">": polygon.with(n: 3, angle: 90deg),
"^": polygon.with(n: 3),
"v": polygon.with(n: 3, angle: 180deg),
"o": circle,
"s": square,
"polygon": polygon,
"d": polygon.with(n: 4),
"p5": polygon.with(n: 5),
"p6": polygon.with(n: 6),
"p7": polygon.with(n: 7),
"p8": polygon.with(n: 8),
"star": star,
"s3": star.with(n: 3, inset: 70%),
"s4": star.with(n: 4),
"s5": star.with(n: 5),
"s6": star.with(n: 6),
"moon": moon,
"text": text-mark,
"none": mark => none
)

View File

@@ -0,0 +1,52 @@
#import "@preview/elembic:1.1.1" as e
#import "@preview/tiptoe:0.3.1"
/// The spine of a diagram axis (the line drawn along the axis).
#let spine(
/// How to stroke the spine of the axis.
/// -> none | stroke
stroke: (thickness: 0.5pt, cap: "square"),
/// Places an arrow tip on the axis spine. This expects a mark as specified by
/// the #link("https://typst.app/universe/package/tiptoe")[tiptoe package].
/// -> none | tiptoe.mark
tip: none,
/// Places an arrow tail on the axis spine. This expects a mark as specified by
/// the #link("https://typst.app/universe/package/tiptoe")[tiptoe package].
/// -> none | tiptoe.mark
toe: none
) = {}
#let spine = e.element.declare(
"spine",
prefix: "lilaq",
display: it => {
if it.stroke == none { return }
let line
if it.kind == "x" {
line = tiptoe.line.with(tip: it.tip, toe: it.toe)
} else if it.kind == "y"{
line = tiptoe.line.with(angle: 90deg, tip: it.toe, toe: it.tip)
}
line(length: 100%, stroke: it.stroke)
},
fields: (
e.field("stroke", e.types.option(stroke), default: (thickness: 0.5pt, cap: "square")),
e.field(
"kind",
e.types.union(e.types.literal("x"), e.types.literal("y")),
default: "x"
),
e.field("tip", e.types.option(function), default: none),
e.field("toe", e.types.option(function), default: none),
)
)

View File

@@ -0,0 +1,206 @@
#import "../process-styles.typ": twod-ify-alignment
#import "@preview/elembic:1.1.1" as e
#import "spine.typ": spine
/// A tick label, usually a number denoting the coordinate value.
/// This type exists prominently to enable `show` rules on tick labels.
#let tick-label(
/// Content to show in the label.
/// -> content
body,
/// Whether this is a label for a subtick.
/// -> bool
sub: false,
/// The kind of axis that the tick label is attached to.
/// -> "x" | "y"
kind: "x",
) = {}
#let tick-label = e.element.declare(
"tick-label",
prefix: "lilaq",
display: it => {
it.body
},
fields: (
e.field("body", e.types.option(content), required: true),
e.field(
"kind",
e.types.union(e.types.literal("x"), e.types.literal("y")),
default: "x"
),
e.field("sub", bool, default: false),
)
)
/// A tick or subtick on a diagram axis. A tick consists of a tick mark on the axis and
/// a tick label, usually a number denoting the coordinate value.
#let tick(
/// Position of the tick in data coordinates.
/// -> float
value,
/// The label for the tick.
/// -> content | lq.tick-label
label: none,
/// Whether this is a subtick.
/// -> bool
sub: false,
/// The kind of axis that the tick is attached to.
/// -> "x" | "y"
kind: "x",
/// How to stroke the tick mark. If set to `auto`, the stroke is inherited
/// the axis spine.
/// -> auto | stroke
stroke: auto,
/// How much to shorten sub ticks compared to regular ticks.
/// -> ratio
shorten-sub: 50%,
/// Where to align the tick. For example, if set to `right`, the tick label is
/// shown to the left of the tick, aligning at its right side. For example,
/// ticks on a $y$-axis on the left side of the diagram will typically be
/// aligned on the right.
/// -> left | top | right | bottom
align: right,
/// The padding to add between the tick and the tick label.
/// -> length
pad: 0.5em,
/// The length of the tick on the inside of the axis. Here, the tick label
/// is considered to be on the _outside_. For example, if @tick.align is set
/// to `right`, the inset determines the tick length to the right of the
/// axis spine.
/// -> length
inset: 4pt,
/// Length of the tick on the outside of the axis, see @tick.inset. For
/// example, if @tick.align is set to `right`, the inset determines the tick
/// length to the left of the axis spine.
/// -> length
outset: 0pt
) = {}
#let tick = e.element.declare(
"tick",
prefix: "lilaq",
display: it => e.get(e-get => {
let angle = if it.align in (top, bottom) { 90deg } else { 0deg }
let factor = if it.sub { 1 - (it.shorten-sub / 100%) } else { 1 }
let outset = it.outset * factor
let length = (it.inset + it.outset) * factor
let stroke = it.stroke
if stroke == auto {
stroke = e-get(spine).stroke
}
let label = it.label
if e.eid(label) != e.eid(tick-label) {
label = tick-label(label, sub: it.sub)
}
let tline = line(length: length, angle: angle, stroke: stroke)
if it.align == right {
move(dx: -outset, {
tline + place(dx: -length - it.pad, right + horizon, label)
})
} else if it.align == left {
move(dx: -length + outset, {
tline + place(dx: length + it.pad, left + horizon, label)
})
} else if it.align == top {
move(dy: -length + outset, {
tline + place(dy: length + it.pad, top + center, label)
});
} else if it.align == bottom {
move(dy: -outset, {
tline + place(dy: -length - it.pad, bottom + center, label)
})
}
}),
labelable: false,
fields: (
e.field("value", float, required: true),
e.field("sub", bool, default: false),
e.field(
"kind",
e.types.union(e.types.literal("x"), e.types.literal("y")),
default: "x"
),
e.field("label", e.types.any, default: none),
e.field("align", e.types.wrap(alignment, fold: none), default: right),
e.field("stroke", e.types.smart(stroke), default: auto),
e.field("shorten-sub", ratio, default: 50%),
e.field("pad", length, default: 0.5em),
e.field("inset", length, default: 3pt),
e.field("outset", length, default: 0pt),
)
)
#box(
stroke: red,
width: 1cm, height: 1cm,
tick(34, label: [304])
)
#box(
stroke: red,
width: 1cm, height: 1cm,
tick(34, label: [304], align: left)
)
#box(
stroke: red,
width: 1cm, height: 1cm,
tick(34, label: [304], align: top)
)
#box(
stroke: red,
width: 1cm, height: 1cm,
tick(34, label: [304], align: bottom)
)
\
\
// #tick(34, label: [34])
// #tick(34, label: [34], align: left)
// #tick(34, label: [34], align: top)
// #tick(34, label: [34], align: bottom)
// #tick(34, label: [34], inset: 2pt)
// #tick(34, label: [34], align: left, inset: 2pt)
// #tick(34, label: [34], align: top, inset: 2pt)
// #tick(34, label: [34], align: bottom, inset: 2pt)

View File

@@ -0,0 +1,94 @@
#import "@preview/elembic:1.1.1" as e
/// A title for a diagram. Titles can be placed at the top (default),
/// left, right, or bottom of a diagram.
#let title(
/// The content to show in the title.
/// -> any
body,
/// Position of the title in the diagram.
/// -> left | top | right | bottom
position: top,
/// Horizontal offset.
/// -> length
dx: 0pt,
/// Vertical offset.
/// -> length
dy: 0pt,
/// Padding between the axes and the title.
/// -> length
pad: 0.5em,
) = {
assert(
position in (top, bottom, left, right),
message: "`position` needs to be one of \"top\", \"left\", \"bottom\", and \"right\""
)
(
body: body,
position: position,
dx: dx,
dy: dy,
pad: pad
)
}
#let title = e.element.declare(
"title",
prefix: "lilaq",
display: it => it.body,
fields: (
e.field("body", content, required: true),
e.field("position", alignment, default: top),
e.field("dx", length, default: 0pt),
e.field("dy", length, default: 0pt),
e.field("pad", length, default: 0.5em),
)
)
#import "../bounds.typ": place-with-bounds
#let lq-title = title
#let _place-title-with-bounds(
title,
get-settable-field,
width,
height
) = {
if e.eid(title) != e.eid(lq-title) {
title = lq-title(title)
}
let position = get-settable-field(lq-title, title, "position")
let dx = get-settable-field(lq-title, title, "dx")
let dy = get-settable-field(lq-title, title, "dy")
let pad = get-settable-field(lq-title, title, "pad")
let wrapper = if position in (top, bottom) {
box.with(width: width)
} else if position in (left, right) {
box.with(height: height)
}
place-with-bounds(
wrapper(title), alignment: position, dx: dx, dy: dy, pad: pad
)
}

View File

@@ -0,0 +1,15 @@
#let place-anchor(x, y, name) = {
(
label: none,
plot: (plot, transform) => {
let (x, y) = transform(x, y)
place(
dx: x, dy: y, [#box()#label(name)]
)
},
xlimits: () => none,
ylimits: () => none,
legend-handle: (..) => none
)
}

View File

@@ -0,0 +1,314 @@
#import "../assertations.typ"
#import "../logic/limits.typ": bar-lim
#import "../logic/time.typ"
#import "../process-styles.typ": merge-fills
#import "../utility.typ": match-type, match
#import "../logic/process-coordinates.typ": filter-nan-points, stepify
#import "../math.typ": vec, minmax
#import "../style/styling.typ": prepare-path
#let render-bar(
plot,
transform,
orientation: "vertical"
) = {
let offset-coeff = plot.offset-coeff
let get-bar-range = match-type(
plot.width,
int: () => i => (
plot.width * offset-coeff, plot.width * (1 + offset-coeff),
),
float: () => i => (
plot.width * offset-coeff, plot.width * (1 + offset-coeff),
),
array: () => i => (
plot.width.at(i) * offset-coeff, plot.width.at(i) * (1 + offset-coeff),
),
)
show: prepare-path.with(
fill: if type(plot.style.fill) != array { plot.style.fill},
stroke: plot.style.stroke,
element: rect
)
let colored-rect = {
if type(plot.style.fill) == array {
(i, width: 0pt, height: 0pt) => rect(
width: width, height: height, fill: plot.style.fill.at(i)
)
} else {
(i, width: 0pt, height: 0pt) => rect(width: width, height: height)
}
}
if "make-legend" in plot {
colored-rect(0, width: 100%, height: 100%)
} else {
if orientation == "vertical" {
for i in range(plot.x.len()) {
let y = plot.y.at(i)
if float.is-nan(y) { continue }
let (x1, x2) = get-bar-range(i)
let x = plot.x.at(i)
let (xx1, y0) = transform(x + x1, plot.base.at(i, default: plot.base.first()))
let (xx2, yy) = transform(x + x2, y)
place(dx: xx1, dy: yy, colored-rect(i, width: xx2 - xx1, height: y0 - yy))
}
} else if orientation == "horizontal" {
for i in range(plot.y.len()) {
let x = plot.x.at(i)
if float.is-nan(x) { continue }
let (y1, y2) = get-bar-range(i)
let y = plot.y.at(i)
let (x0, yy1) = transform(plot.base.at(i, default: plot.base.first()), y + y1)
let (xx, yy2) = transform(x, y + y2)
place(dx: x0, dy: yy2, colored-rect(i, width: xx - x0, height: yy1 - yy2))
}
}
}
}
/// Creates a bar plot from the given bar positions and heights.
///
/// ```example
/// #lq.diagram(
/// xaxis: (subticks: none),
/// lq.bar(
/// (1, 2, 3, 4, 5, 6),
/// (1, 2, 3, 2, 5, 3),
/// )
/// )
/// ```
///
///
/// The example below demonstrates how to use custom tick labels by passing
/// an array of `(location, label)` tuples to @axis.ticks. In addition, we show
/// how to rotate the labels by 45° and align them nicely to the ticks.
/// ```example
/// #lq.diagram(
/// xaxis: (
/// ticks: ("Apples", "Bananas", "Kiwis", "Mangos", "Papayas")
/// .map(rotate.with(-45deg, reflow: true))
/// .map(align.with(right))
/// .enumerate(),
/// subticks: none,
/// ),
/// lq.bar(
/// range(5),
/// (5, 3, 4, 2, 1)
/// )
/// )
/// ```
#let bar(
/// An array of $x$ coordinates specifying the bar positions.
/// -> array
x,
/// Specifies the bar heights either through an array of $y$ coordinates or
/// a function that takes an `x` value and returns a corresponding `y`
/// coordinate. The number of $x$ and $y$ coordinates must match.
/// -> array | function
y,
/// How to fill the bars. This can be a single value applied to all bars or
/// an array with the same length as the coordinate arrays.
/// -> none | color | gradient | tiling | array
fill: auto,
/// How to stroke the bars. All values allowed by the built-in `rect`
/// function are also allowed here, see
/// [`std.rect#stroke`](https://typst.app/docs/reference/visualize/rect/#parameters-stroke).
/// In particular, note that by passing a dictionary, the individual sides
/// of the bars can be stroked independently.
/// -> auto | none | color | length | stroke | gradient | tiling | dictionary
stroke: none,
/// Alignment of the bars at the $x$ values.
/// #details[
/// Demonstration of the different alignment modes.
/// ```example
/// #lq.diagram(
/// xaxis: (subticks: none),
/// lq.bar(
/// (1,2,3,4,5), (1,2,3,4,5),
/// width: 0.2, fill: red,
/// align: left, label: "left"
/// ),
/// lq.bar(
/// (1,2,3,4,5), (5,4,3,2,1),
/// width: 0.2, fill: blue,
/// align: right, label: "right"
/// ),
/// lq.bar(
/// (1,2,3,4,5), (2.5,) * 5,
/// width: 0.2, fill: rgb("#AAEEAA99"),
/// align: center, label: "center"
/// ),
/// )
/// ```
/// ]
/// -> left | center | right
align: center,
/// Width of the bars in data coordinates. The width can be set either to a
/// constant for all bars or per-bar by passing an array with
/// the same length as the coordinate arrays.
/// #details[
/// Example for a bar plot with varying bar widths.
/// ```example
/// #lq.diagram(
/// lq.bar(
/// (1, 2, 3, 4, 5),
/// (1, 2, 3, 2, 5),
/// width: (1, 0.5, 1, 0.5, 1),
/// fill: orange,
/// )
/// )
/// ```
/// ]
/// -> int | float | array
width: 0.8,
/// An offset to apply to all $x$ coordinates. This is equivalent to replacing
/// the array passed to @bar.x with `x.map(x => x + offset)`. Using an offset
/// can be useful to avoid overlaps when plotting multiple bar plots into one
/// diagram.
/// -> int | float | array
offset: 0,
/// Defines the $y$ coordinate of the baseline of the bars. This can either
/// be a constant value applied to all bars or it can be set per-bar by
/// passing an array with the same length as the coordinate arrays.
/// #details[
/// Bar plot with varying base.
/// ```example
/// #lq.diagram(
/// xaxis: (subticks: none),
/// lq.bar(
/// (1, 2, 3, 4, 5),
/// (1, 2, 3, 0, 5),
/// base: (0, 1, 2, -1, 0),
/// fill: white,
/// stroke: 0.7pt
/// )
/// )
/// ```
/// ]
/// -> int | float | array
base: 0,
/// The legend label for this plot. See @plot.label.
/// -> content
label: none,
/// Whether to clip the plot to the data area. See @plot.clip.
/// -> bool
clip: true,
/// Determines the $z$ position of this plot in the order of rendered diagram
/// objects. See @plot.z-index.
/// -> int | float
z-index: 2,
) = {
if type(y) == function {
y = x.map(y)
}
let datetime-axes = (:)
if type(x.at(0, default: 0)) == datetime {
x = time.to-seconds(..x)
datetime-axes.x = true
}
if type(y.at(0, default: 0)) == datetime {
y = time.to-seconds(..y)
datetime-axes.y = true
}
assertations.assert-matching-data-dimensions(
x, y, width: width, base: base, fill: fill, fn-name: "bar"
)
if offset != 0 {
if type(offset) == array {
assertations.assert-matching-data-dimensions(
x, y, offset: offset, fn-name: "bar"
)
x = x.zip(offset).map(array.sum)
} else {
x = x.map(x => x + offset)
}
}
if type(base) != array {
base = (base,)
}
let offset-coeff = match(
align,
left, 0,
center, -0.5,
right, -1
)
let simple-lims() = vec.add(
minmax(x),
(offset-coeff*width, (1 + offset-coeff) * width)
)
let xlim = match-type(
width,
int: simple-lims,
float: simple-lims,
array: () => (
calc.min(..x.zip(width).map(((x, w)) => x + offset-coeff * w)),
calc.max(..x.zip(width).map(((x, w)) => x + (1 + offset-coeff) * w)),
)
)
(
x: x,
y: y,
width: width,
offset-coeff: offset-coeff,
label: label,
base: base,
style: (
align: align,
stroke: stroke,
fill: fill
),
plot: render-bar,
xlimits: () => xlim,
ylimits: () => bar-lim(y, base),
datetime: datetime-axes,
legend: true,
ignores-cycle: false,
clip: clip,
z-index: z-index
)
}

View File

@@ -0,0 +1,344 @@
#import "../assertations.typ"
#import "../utility.typ" as utility: match
#import "../algorithm/boxplot.typ": *
#import "../style/styling.typ": mark, prepare-mark
#import "../logic/time.typ"
#let render-boxplot(plot, transform) = {
if "make-legend" in plot {
return std.line(length: 100%, stroke: plot.style.stroke)
}
let style = plot.style
for (i, statistics) in plot.statistics.enumerate() {
let x1 = plot.width.at(i) * -0.5
let x2 = plot.width.at(i) * 0.5
let x = plot.x.at(i)
let (xx1, q1) = transform(x + x1, statistics.q1)
let (xx2, q3) = transform(x + x2, statistics.q3)
let (middle, median) = transform(x, statistics.median)
let (_, whisker-low) = transform(x, statistics.whisker-low)
let (_, whisker-high) = transform(x, statistics.whisker-high)
let (xm1, _) = transform(x - style.cap-length / 2, statistics.q1)
let (xm2, _) = transform(x + style.cap-length / 2, statistics.q1)
let box-thickness = utility.if-auto(stroke(style.stroke).thickness, 1pt)
// x axis may be inverted:
if xx1 > xx2 { box-thickness *= -1 }
place(line(
start: (middle, q1),
end: (middle, whisker-low),
stroke: style.whisker
))
place(line(
start: (middle, q3),
end: (middle, whisker-high),
stroke: style.whisker
))
place(line(
start: (xm1, whisker-low),
end: (xm2, whisker-low),
stroke: style.cap
))
place(line(
start: (xm1, whisker-high),
end: (xm2, whisker-high),
stroke: style.cap
))
place(
dx: xx1, dy: q1,
rect(
height: q3 - q1, width: xx2 - xx1,
fill: style.fill, stroke: style.stroke
)
)
place(line(
start: (xx1 + box-thickness / 2, median),
end: (xx2 - box-thickness / 2, median),
stroke: style.median
))
if style.mean != none {
let (_, mean) = transform(x, statistics.mean)
if type(style.mean) in (str, function) {
show: prepare-mark.with(
func: plot.style.mean,
size: plot.style.mark-size,
fill: plot.style.outlier-fill
)
set mark(stroke: plot.style.outlier-stroke)
place(dx: middle, dy: mean, mark())
} else {
place(line(
start: (xx1 + box-thickness/2, mean),
end: (xx2 - box-thickness/2, mean),
stroke: style.mean
))
}
}
if plot.style.mark != none {
show: prepare-mark.with(
func: plot.style.mark,
size: plot.style.mark-size,
fill: plot.style.outlier-fill
)
set mark(stroke: plot.style.outlier-stroke)
for outlier in statistics.outliers {
let (_, y) = transform(x + x1, outlier)
place(dx: middle, dy: y, mark())
}
}
}
}
/// Computes and visualizes one or more boxplots from datasets.
///
/// In the following example, boxplots are generated for four different
/// datasets. The default boxplot visualizes the first and third quartil as
/// well as the median of the data with a box and shows traditional whiskers
/// and outliers.
/// ```example
/// #lq.diagram(
/// lq.boxplot(
/// stroke: blue.darken(50%),
/// (1, 2, 3, 4, 5, 6, 7, 8, 9, 21, 19),
/// range(1, 30),
/// (1, 28, 25, 30),
/// (1, 2, 3, 4, 5, 6, 32),
/// )
/// )
/// ```
///
/// By default, the mean value is not shown but it can be visualized by setting
/// the @boxplot.mean parameter to a mark or line stroke.
/// ```example
/// #lq.diagram(
/// lq.boxplot((1, 3, 10), mean: "."),
/// lq.boxplot((1, 3, 10), mean: green, x: 2),
/// )
/// ```
///
/// Boxplots can be richly customized as demonstrated below.
/// ```example
/// #lq.diagram(
/// lq.boxplot(
/// (1, 3, 10),
/// stroke: luma(30%),
/// fill: yellow,
/// median: red
/// ),
/// lq.boxplot(
/// (1.5, 3, 9),
/// x: 2,
/// whisker: blue,
/// cap: red,
/// cap-length: 0.7,
/// median: green
/// ),
/// lq.boxplot(
/// lq.linspace(5.3, 6.2) + (2, 3, 7, 9.5),
/// x: 3,
/// outliers: "x"
/// ),
/// lq.boxplot(
/// lq.linspace(5.3, 6.2) + (2, 3, 7, 9.5),
/// x: 4,
/// outliers: none
/// ),
/// )
/// ```
///
/// Some data sets might be too large to be processed in Typst. In this case,
/// the median, the first and third quartil as well as the whiskers can be
/// computed somewhere else and specified manually in Lilaq (see also
/// @boxplot.data).
/// ```example
/// #lq.diagram(
/// width: 4cm,
/// lq.boxplot(
/// (
/// median: 4.4,
/// q1: 2,
/// q3: 8,
/// outliers: (12, 13),
/// whisker-low: 0,
/// whisker-high: 10,
/// ),
/// )
/// )
/// ```
#let boxplot(
/// One or more data sets to generate a boxplot from. A data set can either be
/// - an array of values (in this case all statistics are computed automatically) or
/// - a dictionary with the mandatory keys `median`, `q1`, `q3`,
/// `whisker-low`, and `whisker-high` and optional keys `mean` and
/// `outliers`. This method is useful for precomputed distributions,
/// especially data sets that are too large for processing in Typst.
///
/// -> array | dictionary
..data,
/// The $x$ coordinate(s) to draw the boxplots at. If set to `auto`, boxplots will
/// be created at integer positions starting with 1.
/// -> auto | int | float | array
x: auto,
/// The position of the whiskers. The length of the whiskers is at most
/// `whisker-pos * (q3 - q1)` where `q1` and `q3` are the first and third quartils.
/// However, the whiskers always end at an actual data point, so the length can be
/// less then that. The default value of 1.5 is a very common convention established
/// by John Tukey in _Exploratory data analysis_ (1977).
/// -> int | float
whisker-pos: 1.5,
/// The width of the boxplots in $x$ data coordinates. This can be a constant width
/// applied to all boxplots or an array of widths matching the number of data sets.
/// -> int | float | array
width: 0.5,
/// How to fill the boxes.
/// -> none | color | gradient | tiling
fill: none,
/// How to stroke the boxplot in general. Also see @boxplot.whisker, @boxplot.cap.
/// -> length | color | stroke | gradient | tiling | dictionary
stroke: 1pt + black,
/// How to stroke the line that indicates the median of the data.
/// -> length | color | stroke | gradient | tiling | dictionary
median: 1pt + orange,
/// Whether and how to display the mean value. The mean value can be
/// visualized with a mark (see @plot.mark) or a line like the median.
/// -> none | lq.mark | str | stroke
mean: none,
/// How to stroke the whiskers. If set to `auto`, the stroke is inherited from
/// @boxplot.stroke.
/// -> auto | length | color | stroke | gradient | tiling | dictionary
whisker: auto,
/// How to stroke the caps of the whiskers. If set to `auto`, the stroke is inherited
/// from @boxplot.stroke.
/// -> auto | length | color | stroke | gradient | tiling | dictionary
cap: auto,
/// The length of the whisker caps in $x$ data coordinates.
/// -> int | float
cap-length: 0.25,
/// Whether and how to display outliers. See @plot.mark.
/// -> none | lq.mark | str
outliers: "o",
/// The size of the marks used to visualize outliers.
/// -> length
outlier-size: 5pt,
/// How to fill outlier marks.
/// -> none | auto | color
outlier-fill: none,
/// How to stroke outlier marks.
/// -> stroke
outlier-stroke: black,
/// The legend label for this plot. See @plot.label.
/// -> content
label: none,
/// Whether to clip the plot to the data area. See @plot.clip.
/// -> bool
clip: true,
/// Determines the $z$ position of this plot in the order of rendered diagram
/// objects. See @plot.z-index.
/// -> int | float
z-index: 2,
) = {
assertations.assert-no-named(data)
data = data.pos()
let num-boxplots = data.len()
if type(x) in (int, float, datetime) { x = (x,) }
else if x == auto { x = range(1, num-boxplots + 1) }
let datetime-axes = (:)
if type(x.at(0, default: 0)) == datetime {
x = time.to-seconds(..x)
datetime-axes.x = true
}
assert(
x.len() == num-boxplots,
message: "The number of x coordinates does not match the number of data arrays"
)
if type(width) in (int, float) { width = (width,) * num-boxplots }
assert(
width.len() == num-boxplots,
message: "The number of widths does not match the number of data arrays"
)
if whisker == auto { whisker = utility.if-none(stroke, std.stroke()) }
if cap == auto { cap = utility.if-none(stroke, std.stroke()) }
let statistics = data.map(boxplot-statistics.with(whiskers: whisker-pos))
let all-outliers = ()
if outliers != none {
all-outliers = statistics.map(boxplot => boxplot.outliers).flatten()
}
let ymax = calc.max(..statistics.map(x => x.whisker-high), ..all-outliers)
let ymin = calc.min(..statistics.map(x => x.whisker-low), ..all-outliers)
let xmin = x.at(0) - width.at(0)
let xmax = x.at(-1) + width.at(-1)
(
x: x,
statistics: statistics,
label: label,
width: width,
style: (
fill: fill,
stroke: stroke,
cap: cap,
cap-length: cap-length,
whisker: whisker,
median: median,
mean: mean,
mark: if outliers != none { outliers },
mark-size: outlier-size,
outlier-fill: outlier-fill,
outlier-stroke: outlier-stroke,
),
plot: render-boxplot,
xlimits: () => (xmin, xmax),
ylimits: () => (ymin, ymax),
datetime: datetime-axes,
legend: true,
clip: clip,
z-index: z-index
)
}

View File

@@ -0,0 +1,259 @@
#import "../assertations.typ"
#import "../math.typ": sign, mesh
#import "../logic/sample-colors.typ": sample-colors
#let are-dimensions-all-equal(data) = {
if data.len() <= 1 { return true }
let x0 = data.first()
return data.slice(1).all(x => calc.abs(1 - x/x0) < 1e-8)
}
#assert(are-dimensions-all-equal((1, 1, 1)))
#assert(not are-dimensions-all-equal((1, 1, 2)))
#assert(not are-dimensions-all-equal((1, 1, 1.0001)))
#assert(are-dimensions-all-equal((1, 1, 1.000000001)))
#let render-colormesh(plot, transform) = {
if "make-legend" in plot {
return box(width: 100%, height: 100%, fill: plot.color.at(0))
}
let get-extents(a) = {
a.zip(a.slice(1)).map(((a, a1)) => a1 - a)
}
let get-size(i, a) = {
if i < a.len() - 1 {
let diff = a.at(i + 1) - a.at(i)
if i > 0 {
(a.at(i) - a.at(i - 1), diff)
} else {
(diff, diff)
}
}
else {
let diff = a.at(-1) - a.at(-2)
(diff, diff)
}
}
let widths = get-extents(plot.x)
let heights = get-extents(plot.y)
if are-dimensions-all-equal(widths) and are-dimensions-all-equal(heights) {
let (x0, xn) = (plot.x.at(0), plot.x.at(-1))
let (y0, yn) = (plot.y.at(0), plot.y.at(-1))
let w = widths.at(0)
let h = heights.at(0)
let (x1, y1) = transform(x0 - w / 2, y0 - h / 2)
let (x2, y2) = transform(xn + w / 2, yn + h / 2)
let img = image(
bytes(plot.color.map(c => rgb(c).components().map(x => int(x / 100% * 255))).join()),
format: (
encoding: "rgba8",
width: plot.x.len(),
height: plot.y.len(),
),
scaling: plot.interpolation,
width: calc.abs(x2 - x1),
fit: "stretch",
height: calc.abs(y2 - y1)
)
if x1 > x2 {
img = scale(origin: left, x: -100%, img)
}
if y1 > y2 {
img = scale(origin: top, y: -100%, img)
}
place(
top + left,
dx: x1,
dy: y1,
img
)
} else {
assert(
plot.interpolation == "pixelated",
message: "For non-evenly-spaced color meshes, currently only the interpolation option \"pixelated\" is supported. "
)
for i in range(plot.x.len()) {
for j in range(plot.y.len()) {
let x = plot.x.at(i)
let y = plot.y.at(j)
let (w1, w2) = get-size(i, plot.x)
let (h1, h2) = get-size(j, plot.y)
let (x1, y1) = transform(x - w1 / 2, y + h2 / 2)
let (x2, y2) = transform(x + w2 / 2, y - h1 / 2)
let fill = plot.color.at(i + j * plot.x.len())
let width = x2 - x1
let height = y2 - y1
place(
dx: x1, dy: y1,
rect(width: width * 1.01, height: height * 1.01, fill: fill)
)
}
}
}
}
/// Plots a rectangular color mesh, e.g., a heatmap.
/// ```example
/// #lq.diagram(
/// width: 4cm, height: 4cm,
/// lq.colormesh(
/// lq.linspace(-4, 4, num: 10),
/// lq.linspace(-4, 4, num: 10),
/// (x, y) => x * y,
/// map: color.map.magma
/// )
/// )
/// ```
///
/// When the input `x` and `y` coordinate arrays are both evenly spaced, an
/// image is drawn instead of individual rectangles. This reduces the file size
/// and improves rendering in most cases. When either array is not evenly
/// spaced, the entire color mesh is drawn with individual rectangles.
///
#let colormesh(
/// A one-dimensional array of $x$ coordinates.
/// -> array
x,
/// A one-dimensional array of $y$ coordinates.
/// -> array
y,
/// Specifies the $z$ coordinates (height) for all combinations of $x$ and $y$
/// coordinates. This can either be a
/// - two-dimensional $m×n$-array where $m$ is the length of @colormesh.y
/// and $n$ is the length of @colormesh.x (for each $y$ value, a row of $x$
/// values),
/// - or a function that takes an `x` and a `y` value and returns a
/// corresponding `z` coordinate.
/// Also see the function @mesh that can be used to create such meshes.
///
/// For masking, you can use `float.nan` values to hide individual cells of the color mesh.
/// -> array | function
z,
/// A color map in the form of a gradient or an array of colors to sample from.
/// -> array | gradient
map: color.map.viridis,
/// Sets the data value that corresponds to the first color of the color map.
/// If set to `auto`, it defaults to the minimum $z$ value.
/// -> auto | int | float
min: auto,
/// Sets the data value that corresponds to the last color of the color map.
/// If set to `auto`, it defaults to the maximum $z$ value.
/// -> auto | int | float
max: auto,
/// Determines how values outside the range defined by @colormesh.min and
/// @colormesh.max are handled.
///
/// - `"clamp"`: Values below @colormesh.min are mapped to the first color of
/// the color map, values above @colormesh.max are mapped to the last color.
/// - `"mask"`: Values outside the range are not drawn and appear transparent.
///
/// -> "clamp" | "mask"
excess: "clamp",
/// The normalization method used to scale $z$ coordinates to the range
/// $[0,1]$ before mapping them to colors using the color map. This can be a
/// @scale, a string that is the identifier of a built-in scale or a function
/// that takes one argument (for example the argument `x => calc.log(x)`
/// would be equivalent to passing `"log"`). Note that the function does not
/// actually need to map the values to the interval $[0,1]$. Instead it
/// describes a scaling that is applied before the data set is _linearly_
/// scaled to the interval $[0,1]$.
/// -> lq.scale | str | function
norm: "linear",
/// Whether to apply smoothing or leave the color mesh pixelated. This is
/// currently only supported when @colormesh.x and @colormesh.y are evenly
/// spaced.
/// -> "pixelated" | "smooth"
interpolation: "pixelated",
/// The legend label for this plot. See @plot.label.
/// -> content
label: none,
/// Determines the $z$ position of this plot in the order of rendered diagram
/// objects. See @plot.z-index.
/// -> int | float
z-index: 2
) = {
if type(z) == function {
z = mesh(x, y, z)
}
assert.eq(
y.len(), z.len(),
message: "`colormesh`: The number of `y` coordinates and the number of rows in `z` must match. Found " + str(y.len()) + " != " + str(z.len())
)
assert(
type(z) == array and type(z.first()) == array,
message: "`colormesh`: `z` expects a 2D array"
)
assert.eq(
x.len(), z.first().len(),
message: "`colormesh`: The number of `x` coordinates and the row length in `z` must match. Found " + str(x.len()) + " != " + str(z.first().len())
)
assert(
excess in ("clamp", "mask"),
message: "`colormesh`: Invalid value for argument `excess`. Expected \"clamp\" or \"mask\", found \"" + str(excess) + "\""
)
let color = z.flatten()
let cinfo
if type(color.at(0, default: 0)) in (int, float) {
(color, cinfo) = sample-colors(
color,
map,
norm,
ignore-nan: true,
min: min,
max: max,
excess: excess
)
}
(
cinfo: cinfo,
x: x,
y: y,
z: z,
label: label,
color: color,
plot: render-colormesh,
interpolation: interpolation,
xlimits: () => (
1fr * (x.at(0) - 0.5 * (x.at(1) - x.at(0))),
1fr * (x.at(-1) + 0.5 * (x.at(-1) - x.at(-2)))
),
ylimits: () => (
1fr * (y.at(0) - 0.5 * (y.at(1) - y.at(0))),
1fr * (y.at(-1) + 0.5 * (y.at(-1) - y.at(-2)))
),
legend: true,
z-index: z-index
)
}

View File

@@ -0,0 +1,222 @@
#import "../assertations.typ"
#import "../algorithm/contour.typ": close-path-at-boundaries, compute-polygon-orientation
#import "../logic/sample-colors.typ": sample-colors
#import "../math.typ": minmax, mesh
#let render-contour(plot, transform) = {
if "make-legend" in plot {
return std.line(length: 100%, stroke: plot.line-colors.first())
}
if plot.fill {
let (xmin, xmax) = minmax(plot.x)
let (ymin, ymax) = minmax(plot.y)
let canvas-rect = ((xmax, ymax), (xmax, ymin), (xmin, ymin), (xmin, ymax), (xmax, ymax)) // right-turning curve
// assert.eq(compute-polygon-orientation(..canvas-rect), right)
for (i, contour) in plot.contours.enumerate() {
set curve(stroke: none, fill: plot.line-colors.at(i))
let to-closed-curve(contour) = {
if contour.len() == 0 { return () }
contour = contour.map(p => transform(..p))
return (
curve.move(contour.first()),
..contour.slice(1).map(curve.line),
curve.close(),
)
}
if i == 0 {
contour = (canvas-rect,)
}
if contour.len() == 0 { continue }
if compute-polygon-orientation(..contour.first()) == left {
contour = (canvas-rect,) + contour
}
place(curve(
..contour.map(to-closed-curve).join()
))
}
} else {
for (i, contour) in plot.contours.enumerate() {
set curve(stroke: plot.line-colors.at(i))
set curve(stroke: plot.stroke)
for path in contour {
path = path.map(p => transform(..p))
place(std.curve(
curve.move(path.first()),
..path.slice(1).map(curve.line)
))
}
}
}
}
/// Creates a contour plot for a 3-dimensional mesh. Given a set of `levels`,
/// a number of cuts through the mesh are computed automatically and displayed
/// as contour lines. Contour plots can be either just stroked
///
/// ```example
/// #lq.diagram(
/// width: 4cm, height: 4cm,
/// lq.contour(
/// lq.linspace(-5, 5, num: 12),
/// lq.linspace(-5, 5, num: 12),
/// (x, y) => x * y,
/// map: color.map.icefire,
/// )
/// )
/// ```
/// or filled per-level.
/// ```example
/// #lq.diagram(
/// width: 4cm, height: 4cm,
/// lq.contour(
/// lq.linspace(-5, 5, num: 12),
/// lq.linspace(-5, 5, num: 12),
/// (x, y) => x * y,
/// map: color.map.icefire,
/// fill: true
/// )
/// )
/// ```
#let contour(
/// A one-dimensional array of $x$ data coordinates.
/// -> array
x,
/// A one-dimensional array of $y$ data coordinates.
/// -> array
y,
/// Specifies the $z$ coordinates (height) for all combinations of $x$ and $y$
/// coordinates. This can either be a
/// - two-dimensional $m×n$-array where $m$ is the length of @contour.y
/// and $n$ is the length of @contour.x (for each $y$ value, a row of $x$
/// values),
/// - or a function that takes an `x` and a `y` value and returns a
/// corresponding `z` coordinate.
/// Also see the function @mesh that can be used to create such meshes.
/// -> array | function
z,
/// Specifies the levels to compute contours for. If this is an integer, an
/// according number of levels is automatically selected evenly from a ticking
/// pattern. TODO: unclear.
/// The desired levels can also be selected manually by passing an array of ($z$)
/// coordinates.
/// -> int | array
levels: 10,
/// Whether to fill the contour levels.
/// -> bool
fill: false,
/// How to stroke the contours in the cases that `fill: false`. If this
/// argument specifies a color, the coloring from the @contour.map is
/// overridden.
/// -> stroke
stroke: 0.7pt,
/// A color map in form of a gradient or an array of colors to sample from.
/// -> array | gradient
map: color.map.viridis,
/// Sets the data value that corresponds to the first color of the color map. If set
/// to `auto`, it defaults to the minimum $z$ value.
/// -> auto | int | float
min: auto,
/// Sets the data value that corresponds to the last color of the color map. If set
/// to `auto`, it defaults to the maximum $z$ value.
/// -> auto | int | float
max: auto,
/// The normalization method used to scale $z$ coordinates to the range
/// $[0,1]$ before mapping them to colors using the color map. This can be a
/// @scale, a string that is the identifier of a built-in scale or a function
/// that takes one argument. See @colormesh.norm.
/// -> lq.scale | str | function
norm: "linear",
/// The legend label for this plot. See @plot.label.
/// -> content
label: none,
/// Determines the $z$ position of this plot in the order of rendered diagram
/// objects. See @plot.z-index.
/// -> int | float
z-index: 2
) = {
if type(z) == function {
z = mesh(x, y, z)
}
assert.eq(
y.len(), z.len(),
message: "`colormesh`: The number of `y` coordinates and the number of rows in `z` must match. Found " + str(y.len()) + " != " + str(z.len())
)
assert(
type(z) == array and type(z.first()) == array,
message: "`colormesh`: `z` expects a 2D array"
)
assert.eq(
x.len(), z.first().len(),
message: "`colormesh`: The number of `x` coordinates and the row length in `z` must match. Found " + str(x.len()) + " != " + str(z.first().len())
)
let z-flat = z.flatten()
let (z0, z1) = (calc.min(..z-flat), calc.max(..z-flat))
if z0 == z1 { z0 -= 1; z1 += 1}
if type(levels) == int {
import "../logic/tick-locate.typ"
let range = tick-locate.linear(z0, z1, num-ticks-suggestion: levels)
levels = range.ticks
}
if min == auto { min = calc.min(..z-flat) }
if max == auto { max = calc.max(..z-flat) }
// if min == auto { min = calc.min(..levels) }
// if max == auto { max = calc.max(..levels) }
let (color, cinfo) = sample-colors(levels, map, norm, min: min, max: max)
import "@preview/komet:0.1.0"
let contours = komet.contour(x, y, z, levels)
if fill {
let boundaries = (xmin: x.first(), xmax: x.last(), ymin: y.first(), ymax: y.last())
contours = contours.map(paths => {
paths.map(close-path-at-boundaries.with(boundaries: boundaries)).filter(x => x != none)
})
}
(
cinfo: cinfo,
x: x,
y: y,
z: z,
levels: levels,
line-colors: color,
contours: contours,
fill: fill,
stroke: stroke,
label: label,
plot: render-contour,
xlimits: () => (x.at(0)*1fr, x.at(-1)*1fr),
ylimits: () => (y.at(0)*1fr, y.at(-1)*1fr),
legend: true,
z-index: z-index
)
}

View File

@@ -0,0 +1,124 @@
#import "../assertations.typ"
#import "../logic/limits.typ": compute-primitive-limits
#import "../logic/process-coordinates.typ": all-data-coordinates, convert-rect
#import "../process-styles.typ": twod-ify-alignment
/// Plots an ellipse or circle with origin `(x, y)`. The origin coordinates as well
/// as width and height can either be given as
/// - data coordinates (`int` or `float`),
/// - or absolute coordinates from the top left corner of the data area (`length`),
/// - or in percent relative to the data area (`ratio`),
/// - or a combination of the latter two (`relative`).
///
/// Note that coordinate types can also be mixed (e.g., a length for `x` and a scalar for `y`).
///
///
/// For example in order to access the center, you can write `(50%, 50%)`.
///
///
/// ```example
/// #lq.diagram(
/// width: 3cm, height: 3cm,
/// lq.ellipse(2, 2, width: 10, height: 4, fill: yellow),
/// lq.ellipse(10, 4, width: 4, height: 4, fill: red),
/// lq.ellipse(50%, 50%, width: 45%, height: 45%, stroke: blue)
/// )
/// ```
///
#let ellipse(
/// The x coordinate of the origin.
/// -> float | relative
x,
/// The y coordinate of the origin.
/// -> float | relative
y,
/// The width of the ellipse.
/// -> auto | float | relative
width: auto,
/// The height of the ellipse.
/// -> auto | float | relative
height: auto,
/// How to align the ellipse at the origin.
/// -> alignment
align: left + top,
/// How to fill the ellipse.
/// -> none | color | gradient | tiling
fill: none,
/// How to stroke the ellipse.
/// -> auto | none | stroke
stroke: auto,
/// How much to pad the content of the ellipse. See the built-in [`std.ellipse#inset`](https://typst.app/docs/reference/visualize/ellipse/#parameters-inset).
/// -> relative | dictionary
inset: 5pt,
/// How much to expand the ellipse beyond its defined size.
/// See the built-in [`std.ellipse#outset`](https://typst.app/docs/reference/visualize/ellipse/#parameters-outset).
/// -> relative | dictionary
outset: 0pt,
/// The legend label for this plot. See @plot.label.
/// -> content
label: none,
/// Whether to clip the plot to the data area. See @plot.clip.
/// -> bool
clip: true,
/// Determines the $z$ position of this plot in the order of rendered diagram
/// objects. See @plot.z-index.
/// -> int | float
z-index: 2,
/// An optional body to place inside the ellipse. If `width` and/or `height` are set to `auto`, they will adapt to the content.
/// -> any
..body
) = {
assertations.assert-no-named(body, fn: "ellipse")
(
x: x,
y: y,
plot: (plot, transform) => {
if "make-legend" in plot {
return std.ellipse(
width: 100%, height: 100%,
fill: fill, stroke: stroke
)
}
let (x1, width, y1, height) = convert-rect(
x, y,
width, height,
transform,
align: twod-ify-alignment(align)
)
place(dx: x1, dy: y1,
std.ellipse(
width: width,
height: height,
fill: fill,
stroke: stroke,
inset: inset,
outset: outset,
body.pos().at(0, default: none)
)
)
},
xlimits: compute-primitive-limits.with((x, if all-data-coordinates((x, width)) { x + width } else { x })),
ylimits: compute-primitive-limits.with((y, if all-data-coordinates((y, height)) { y + height } else { y })),
label: label,
legend: true,
clip: clip,
z-index: z-index
)
}

View File

@@ -0,0 +1,138 @@
#import "../assertations.typ"
#import "../process-styles.typ": merge-strokes, merge-fills
#import "../logic/process-coordinates.typ": filter-nan-points, stepify
#import "../logic/time.typ"
#import "../math.typ": minmax
#import "../style/styling.typ": prepare-path
#let render-fill-between(plot, transform) = {
let y2 = if plot.y2 == none { (0,)*plot.x.len() } else { plot.y2 }
let (points, runs) = filter-nan-points(plot.x.zip(plot.y1, y2), generate-runs: true)
show: prepare-path.with(
fill: plot.style.fill,
stroke: plot.style.stroke,
element: polygon
)
if "make-legend" in plot {
polygon((0%, 0%), (0%, 100%), (100%, 100%), (100%, 0%))
} else {
for run in runs {
let there = run.map(x => x.slice(0,2))
let back = run.map(x => (x.at(0), x.at(2)))
if plot.style.step != none {
there = stepify(there, step: plot.style.step)
back = stepify(back, step: plot.style.step)
}
place(polygon(
..((there + back.rev()).map(p => transform(..p))))
)
}
}
}
/// Fills the area between two graphs.
///
/// ```example
/// #let xs = lq.linspace(-1, 2)
/// #lq.diagram(
/// lq.fill-between(
/// xs,
/// xs.map(calc.sin),
/// y2: xs.map(calc.cos),
/// )
/// )
/// ```
/// or the area between one graph and the $x$-axis:
/// ```example
/// #let xs = lq.linspace(0, 3, num: 80)
/// #lq.diagram(
/// lq.fill-between(
/// label: [Maxwell-distribution],
/// xs,
/// xs.map(x => x*x*calc.exp(-x*x*1.3)),
/// )
/// )
/// ```
#let fill-between(
/// An array of $x$ data coordinates. Data coordinates need to be of type `int` or `float`.
/// -> array
x,
/// Specifies either an array of $y$ coordinates or a function that takes an
/// `x` value and returns a corresponding `y` coordinate. The number of $x$
/// and $y$ coordinates must match.
/// -> array | function
y1,
/// An second array (or function) of $y$ data coordinates. If this is `none`,
/// the area between the coordinates `y1` and the $x$-axis is filled.
/// -> none | array | function
y2: none,
/// How to stroke the area.
/// -> none | stroke
stroke: none,
/// How to fill the area.
/// -> none | color | gradient | tiling
fill: auto,
/// Step mode for plotting the lines. See @plot.step.
/// -> none | start | end | center
step: none,
/// The legend label for this plot. See @plot.label.
/// -> content
label: none,
/// Determines the $z$ position of this plot in the order of rendered diagram
/// objects. See @plot.z-index.
/// -> int | float
z-index: 2,
) = {
if type(y1) == function {
y1 = x.map(y1)
}
if type(y2) == function {
y2 = x.map(y2)
}
let datetime-axes = (:)
if type(x.at(0, default: 0)) == datetime {
x = time.to-seconds(..x)
datetime-axes.x = true
}
assertations.assert-matching-data-dimensions(x, x, y1: y1, y2: y2, fn-name: "fill-between")
assert(step in (none, start, end, center))
(
x: x,
y1: y1,
y2: y2,
label: label,
style: (
fill: fill,
stroke: stroke,
step: step,
),
plot: render-fill-between,
xlimits: () => minmax(x),
ylimits: () => minmax(y1 + y2 + if y2 == none {(0,)}),
datetime: datetime-axes,
legend: true,
ignores-cycle: false,
z-index: z-index
)
}

View File

@@ -0,0 +1,202 @@
#import "bar.typ": *
/// Creates a horizontal bar plot from the given bar positions and lengths.
///
/// ```example
/// #lq.diagram(
/// yaxis: (subticks: none),
/// lq.hbar(
/// (1, 2, 3, 2, 5, 3),
/// (1, 2, 3, 4, 5, 6),
/// )
/// )
/// ```
///
/// Also see @bar.
#let hbar(
/// An array of $x$ coordinates specifying the bar lengths.
/// -> array
x,
/// An array of $y$ coordinates specifying the bar positions. The number of
/// $x$ and $y$ coordinates must match.
/// -> array
y,
/// How to fill the bars. This can be a single value applied to all bars or
/// an array with the same length as the coordinate arrays.
/// -> none | color | gradient | tiling | array
fill: auto,
/// How to stroke the bars. All values allowed by the built-in `rect`
/// function are also allowed here, see
/// [`std.rect#stroke`](https://typst.app/docs/reference/visualize/rect/#parameters-stroke).
/// In particular, note that by passing a dictionary, the individual sides
/// of the bars can be stroked independently.
/// -> auto | none | color | length | stroke | gradient | tiling | dictionary
stroke: none,
/// Alignment of the bars at the $y$ values.
/// #details[
/// Demonstration of the different alignment modes.
/// ```example
/// #lq.diagram(
/// yaxis: (subticks: none),
/// lq.hbar(
/// (1,2,3,4,5), (1,2,3,4,5),
/// width: 0.2, fill: red,
/// align: top, label: "top"
/// ),
/// lq.hbar(
/// (5,4,3,2,1), (1,2,3,4,5),
/// width: 0.2, fill: blue,
/// align: bottom, label: "bottom"
/// ),
/// lq.hbar(
/// (2.5,) * 5, (1,2,3,4,5),
/// width: 0.2, fill: rgb("#AAEEAA99"),
/// align: center, label: "center"
/// ),
/// )
/// ```
/// ]
/// -> top | center | bottom
align: center,
/// Width of the bars in data coordinates. The width can be set either to a
/// constant for all bars or per-bar by passing an array with
/// the same length as the coordinate arrays.
/// #details[
/// Example for a bar plot with varying bar widths.
/// ```example
/// #lq.diagram(
/// lq.hbar(
/// (1, 2, 3, 2, 5),
/// (1, 2, 3, 4, 5),
/// width: (1, 0.5, 1, 0.5, 1),
/// fill: orange,
/// )
/// )
/// ```
/// ]
/// -> int | float | array
width: 0.8,
/// An offset to apply to all $y$ coordinates. This is equivalent to replacing
/// the array passed to @bar.y with `y.map(y => y + offset)`. Using an offset
/// can be useful to avoid overlaps when plotting multiple bar plots into one
/// diagram.
/// -> int | float | array
offset: 0,
/// Defines the $x$ coordinate of the baseline of the bars. This can either
/// be a constant value applied to all bars or it can be set per-bar by
/// passing an array with the same length as the coordinate arrays.
/// #details[
/// Bar plot with varying base.
/// ```example
/// #lq.diagram(
/// yaxis: (subticks: none),
/// lq.hbar(
/// (1, 2, 3, 0, 5),
/// (1, 2, 3, 4, 5),
/// base: (0, 1, 2, -1, 0),
/// fill: white,
/// stroke: 0.7pt
/// )
/// )
/// ```
/// ]
/// -> int | float | array
base: 0,
/// The legend label for this plot. See @plot.label.
/// -> content
label: none,
/// Whether to clip the plot to the data area. See @plot.clip.
/// -> bool
clip: true,
/// Determines the $z$ position of this plot in the order of rendered diagram
/// objects. See @plot.z-index.
/// -> int | float
z-index: 2,
) = {
let datetime-axes = (:)
if type(x.at(0, default: 0)) == datetime {
x = time.to-seconds(..x)
datetime-axes.x = true
}
if type(y.at(0, default: 0)) == datetime {
y = time.to-seconds(..y)
datetime-axes.y = true
}
assertations.assert-matching-data-dimensions(
x, y, width: width, base: base, fn-name: "hbar"
)
if offset != 0 {
if type(offset) == array {
assertations.assert-matching-data-dimensions(
x, y, offset: offset, fn-name: "bar"
)
y = y.zip(offset).map(array.sum)
} else {
y = y.map(y => y + offset)
}
}
if type(base) != array {
base = (base,)
}
let offset-coeff = match(
align,
top, 0,
center, -0.5,
bottom, -1
)
let simple-lims() = vec.add(
minmax(y),
(offset-coeff*width, (1 + offset-coeff) * width)
)
let ylim = match-type(
width,
int: simple-lims,
float: simple-lims,
array: () => (
calc.min(..y.zip(width).map(((y, w)) => y + offset-coeff * w)),
calc.max(..y.zip(width).map(((y, w)) => y + (1 + offset-coeff) * w)),
)
)
(
x: x,
y: y,
width: width,
offset-coeff: offset-coeff,
label: label,
base: base,
style: (
align: align,
stroke: stroke,
fill: fill
),
plot: render-bar.with(orientation: "horizontal"),
xlimits: () => bar-lim(x, base),
ylimits: () => ylim,
datetime: datetime-axes,
legend: true,
ignores-cycle: false,
clip: clip,
z-index: z-index
)
}

View File

@@ -0,0 +1,286 @@
#import "../assertations.typ"
#import "../utility.typ" as utility: match
#import "../algorithm/boxplot.typ": *
#import "../style/styling.typ": mark, prepare-mark
#import "../logic/time.typ"
#let render-boxplot(plot, transform) = {
if "make-legend" in plot {
return std.line(length: 100%, stroke: plot.style.stroke)
}
let style = plot.style
for (i, statistics) in plot.statistics.enumerate() {
let y1 = plot.width.at(i) * -0.5
let y2 = plot.width.at(i) * 0.5
let y = plot.y.at(i)
let (q1, yy1) = transform(statistics.q1, y + y1)
let (q3, yy2) = transform(statistics.q3, y + y2)
let (median, middle) = transform(statistics.median, y)
let (whisker-low, _) = transform(statistics.whisker-low, y)
let (whisker-high, _) = transform(statistics.whisker-high, y)
let (_, xm1) = transform(statistics.q1, y - style.cap-length / 2)
let (_, xm2) = transform(statistics.q1, y + style.cap-length / 2)
let box-thickness = utility.if-auto(stroke(style.stroke).thickness, 1pt)
// y axis may be inverted:
if yy1 < yy2 { box-thickness *= -1 }
place(line(
start: (q1, middle),
end: (whisker-low, middle),
stroke: style.whisker
))
place(line(
start: (q3, middle),
end: (whisker-high, middle),
stroke: style.whisker
))
place(line(
start: (whisker-low, xm1),
end: (whisker-low, xm2),
stroke: style.cap
))
place(line(
start: (whisker-high, xm1),
end: (whisker-high, xm2),
stroke: style.cap
))
place(
dx: q1, dy: yy1,
rect(
height: yy2 - yy1,
width: q3 - q1,
fill: style.fill, stroke: style.stroke
)
)
place(line(
start: (median, yy1 - box-thickness / 2),
end: (median, yy2 + box-thickness / 2),
stroke: style.median
))
if style.mean != none {
let (mean, _) = transform(statistics.mean, y)
if type(style.mean) in (str, function) {
show: prepare-mark.with(
func: plot.style.mean,
size: plot.style.mark-size,
fill: plot.style.outlier-fill
)
set mark(stroke: plot.style.outlier-stroke)
place(dx: mean, dy: middle, mark())
} else {
place(line(
start: (mean, yy1 - box-thickness/2),
end: (mean, yy2 + box-thickness/2),
stroke: style.mean
))
}
}
if plot.style.mark != none {
show: prepare-mark.with(
func: plot.style.mark,
size: plot.style.mark-size,
fill: plot.style.outlier-fill
)
set mark(stroke: plot.style.outlier-stroke)
for outlier in statistics.outliers {
let (x, _) = transform(outlier, y + y1)
place(dx: x, dy: middle, mark())
}
}
}
}
/// Computes and visualizes one or more boxplots from datasets.
///
/// ```example
/// #lq.diagram(
/// lq.hboxplot(
/// stroke: blue.darken(50%),
/// (1, 2, 3, 4, 5, 6, 7, 8, 9, 21, 19),
/// range(1, 30),
/// (1, 28, 25, 30),
/// (1, 2, 3, 4, 5, 6, 32),
/// )
/// )
/// ```
///
/// This is the horizontal version of @boxplot. There you can find a more
/// detailed documentation.
#let hboxplot(
/// One or more data sets to generate a boxplot from. A data set can either be
/// - an array of values (in this case all statistics are computed automatically) or
/// - a dictionary with the mandatory keys `median`, `q1`, `q3`,
/// `whisker-low`, and `whisker-high` and optional keys `mean` and
/// `outliers`. This method is useful for precomputed distributions,
/// especially data sets that are too large for processing in Typst.
///
/// -> array | dictionary
..data,
/// The $y$ coordinate(s) to draw the boxplots at. If set to `auto`, boxplots will
/// be created at integer positions starting with 1.
/// -> auto | int | float | array
y: auto,
/// The position of the whiskers. The length of the whiskers is at most
/// `whisker-pos * (q3 - q1)` where `q1` and `q3` are the first and third quartils.
/// However, the whiskers always end at an actual data point, so the length can be
/// less then that. The default value of 1.5 is a very common convention established
/// by John Tukey in _Exploratory data analysis_ (1977).
/// -> int | float
whisker-pos: 1.5,
/// The width of the boxplots in $y$ data coordinates. This can be a constant width
/// applied to all boxplots or an array of widths matching the number of data sets.
/// -> int | float | array
width: 0.5,
/// How to fill the boxes.
/// -> none | color | gradient | tiling
fill: none,
/// How to stroke the boxplot in general. Also see @boxplot.whisker, @boxplot.cap.
/// -> length | color | stroke | gradient | tiling | dictionary
stroke: 1pt + black,
/// How to stroke the line that indicates the median of the data.
/// -> length | color | stroke | gradient | tiling | dictionary
median: 1pt + orange,
/// Whether and how to display the mean value. The mean value can be
/// visualized with a mark (see @plot.mark) or a line like the median.
/// -> none | lq.mark | str | stroke
mean: none,
/// How to stroke the whiskers. If set to `auto`, the stroke is inherited from
/// @boxplot.stroke.
/// -> auto | length | color | stroke | gradient | tiling | dictionary
whisker: auto,
/// How to stroke the caps of the whiskers. If set to `auto`, the stroke is inherited
/// from @boxplot.stroke.
/// -> auto | length | color | stroke | gradient | tiling | dictionary
cap: auto,
/// The length of the whisker caps in $y$ data coordinates.
/// -> int | float
cap-length: 0.25,
/// Whether and how to display outliers. See @plot.mark.
/// -> none | lq.mark | str
outliers: "o",
/// The size of the marks used to visualize outliers.
/// -> length
outlier-size: 5pt,
/// How to fill outlier marks.
/// -> none | auto | color
outlier-fill: none,
/// How to stroke outlier marks.
/// -> stroke
outlier-stroke: black,
/// The legend label for this plot. See @plot.label.
/// -> content
label: none,
/// Whether to clip the plot to the data area. See @plot.clip.
/// -> bool
clip: true,
/// Determines the $z$ position of this plot in the order of rendered diagram
/// objects. See @plot.z-index.
/// -> int | float
z-index: 2,
) = {
assertations.assert-no-named(data)
data = data.pos()
let num-boxplots = data.len()
if type(y) in (int, float) { y = (y,) }
else if y == auto { y = range(1, num-boxplots + 1) }
let datetime-axes = (:)
if type(y.at(0, default: 0)) == datetime {
y = time.to-seconds(..y)
datetime-axes.y = true
}
assert(
y.len() == num-boxplots,
message: "The number of y coordinates does not match the number of data arrays"
)
if type(width) in (int, float) { width = (width,) * num-boxplots }
assert(
width.len() == num-boxplots,
message: "The number of widths does not match the number of data arrays"
)
if whisker == auto { whisker = utility.if-none(stroke, std.stroke()) }
if cap == auto { cap = utility.if-none(stroke, std.stroke()) }
let statistics = data.map(boxplot-statistics.with(whiskers: whisker-pos))
let all-outliers = ()
if outliers != none {
all-outliers = statistics.map(boxplot => boxplot.outliers).flatten()
}
let xmax = calc.max(..statistics.map(x => x.whisker-high), ..all-outliers)
let xmin = calc.min(..statistics.map(x => x.whisker-low), ..all-outliers)
let ymin = y.at(0) - width.at(0)
let ymax = y.at(-1) + width.at(-1)
(
y: y,
statistics: statistics,
label: label,
width: width,
style: (
fill: fill,
stroke: stroke,
cap: cap,
cap-length: cap-length,
whisker: whisker,
median: median,
mean: mean,
mark: if outliers != none { outliers },
mark-size: outlier-size,
outlier-fill: outlier-fill,
outlier-stroke: outlier-stroke,
),
plot: render-boxplot,
xlimits: () => (xmin, xmax),
ylimits: () => (ymin, ymax),
datetime: datetime-axes,
legend: true,
clip: clip,
z-index: z-index
)
}

View File

@@ -0,0 +1,113 @@
#import "../process-styles.typ": merge-strokes
#import "../math.typ": minmax
#import "../assertations.typ"
#import "../logic/time.typ"
#let render-hlines(plot, transform) = {
let min = plot.min
let max = plot.max
if "make-legend" in plot {
return line(length: 100%, stroke: plot.style.stroke)
}
if min == auto { min = 0% }
else {
(min, _) = transform(min, 1)
}
if max == auto { max = 100% }
else {
(max, _) = transform(max, 1)
}
for y in plot.y {
let (_, yy) = transform(1, y)
place(line(start: (min, yy), end: (max, yy), stroke: plot.style.stroke))
}
}
/// Draws a set of horizontal lines into the diagram.
///
/// By default, the lines are indefinite and span the entire width of the
/// diagram, independent of the limits, however, through `min` and `max`,
/// beginning and end of the line can be fixed to an $x$ coordinate,
/// respectively.
/// ```example
/// #lq.diagram(
/// xlim: (0, 7),
/// lq.hlines(1, 1.1, stroke: teal, label: "Indefinite"),
/// lq.hlines(2, stroke: blue, min: 2, label: "Fixed start"),
/// lq.hlines(3, stroke: purple, max: 2, label: "Fixed end"),
/// lq.hlines(4, stroke: red, min: 1, max: 3, label: "Fixed"),
/// )
/// ```
#let hlines(
/// The $y$ coordinate(s) of one or more horizontal lines to draw.
/// -> int | float
..y,
/// The beginning of the line as an $x$ coordinate. If set to `auto`, the line will
/// always start at the left edge of the diagram.
/// -> auto | int | float
min: auto,
/// The end of the line as an $x$ coordinate. If set to `auto`, the line will
/// always end at the right edge of the diagram.
/// -> auto | int | float
max: auto,
/// How to stroke the lines.
/// -> stroke
stroke: black,
/// The legend label for this plot. See @plot.label.
/// -> content
label: none,
/// Determines the $z$ position of this plot in the order of rendered diagram
/// objects. See @plot.z-index.
/// -> int | float
z-index: 2,
) = {
assertations.assert-no-named(y)
y = y.pos()
let datetime-axes = (:)
if type(y.at(0, default: 0)) == datetime {
y = time.to-seconds(..y)
datetime-axes.y = true
}
stroke = merge-strokes(stroke)
let xlimits = none
if min != auto {
xlimits = (min, min)
}
if max != auto {
xlimits = (if min == auto { max } else { min }, max)
}
(
y: y,
label: label,
min: min,
max: max,
style: (
stroke: stroke,
),
plot: render-hlines,
xlimits: () => xlimits,
ylimits: () => minmax(y),
datetime: datetime-axes,
legend: true,
z-index: z-index
)
}

View File

@@ -0,0 +1,157 @@
#import "../assertations.typ"
#import "../logic/limits.typ": bar-lim
#import "../logic/time.typ"
#import "../process-styles.typ": merge-strokes, merge-fills
#import "../logic/process-coordinates.typ": filter-nan-points
#import "../math.typ": minmax
#import "../utility.typ": if-auto
#import "../style/styling.typ": mark, prepare-mark, prepare-line
#let render-hstem(plot, transform) = {
let marker = mark()
set line(stroke: plot.style.stroke)
let (ymin, ymax) = (plot.ylimits)()
if "make-legend" in plot {
ymin = 0
ymax = 1
plot.x = (.75,)
plot.y = (.5,)
plot.base = .25
}
let (x0, y1) = transform(plot.base, ymin)
let (x0, y2) = transform(plot.base, ymax)
show: prepare-mark.with(
func: plot.style.mark,
color: merge-fills(plot.style.color),
size: plot.style.mark-size
)
show: prepare-line.with(
stroke: merge-strokes(plot.style.stroke, plot.style.color)
)
let points = filter-nan-points(plot.x.zip(plot.y)).map(p => transform(..p))
points.map(((x, y)) => place(line(start: (x0, y), end: (x, y)))).join()
if plot.style.base-stroke != none {
set line(stroke: (cap: "square"))
place(line(
start: (x0, y1),
end: (x0, y2),
stroke: plot.style.base-stroke
))
}
points.map(((x, y)) => place(dx: x, dy: y, marker)).join()
}
/// Creates a horizontal stem plot.
/// ```example
/// #let ys = lq.linspace(0, 10, num: 20)
///
/// #lq.diagram(
/// lq.hstem(
/// ys.map(calc.cos),
/// ys,
/// color: orange,
/// mark: "d",
/// base-stroke: black,
/// )
/// )
/// ```
///
/// Also see @stem for vertical stem plots.
#let hstem(
/// An array of $x$ coordinates.
/// -> array
x,
/// An array of $y$ coordinates. The number of $x$ and $y$ coordinates
/// must match.
/// -> array
y,
/// Combined color for line and marks. See also the parameter @hstem.line which takes precedence over `color`, if set.
/// -> auto | color
color: auto,
/// The line style to use for this plot (takes precedence over @hstem.color).
/// -> auto | stroke
stroke: auto,
/// The mark to use to mark data points. See @plot.mark.
/// -> auto | none | lq.mark | str
mark: auto,
/// Size of the marks.
/// -> length
mark-size: auto,
/// Defines the $x$ coordinate of the base line.
/// -> int | float
base: 0,
/// How to stroke the base line.
/// -> stroke
base-stroke: red,
/// The legend label for this plot. See @plot.label.
/// -> content
label: none,
/// Whether to clip the plot to the data area. See @plot.clip.
/// -> bool
clip: true,
/// Determines the $z$ position of this plot in the order of rendered diagram
/// objects. See @plot.z-index.
/// -> int | float
z-index: 2,
) = {
let datetime-axes = (:)
if type(x.at(0, default: 0)) == datetime {
x = time.to-seconds(..x)
datetime-axes.x = true
}
if type(y.at(0, default: 0)) == datetime {
y = time.to-seconds(..y)
datetime-axes.y = true
}
assertations.assert-matching-data-dimensions(x, y, fn-name: "hstem")
(
x: x,
y: y,
base: base,
label: label,
style: (
color: color,
mark: mark,
mark-size: mark-size,
stroke: merge-strokes(stroke, color),
base-stroke: base-stroke
),
plot: render-hstem,
xlimits: () => bar-lim(x, (base,)),
ylimits: () => minmax(y),
datetime: datetime-axes,
legend: true,
ignores-cycle: false,
clip: clip,
z-index: z-index
)
}

View File

@@ -0,0 +1,119 @@
#import "../logic/process-coordinates.typ": convert-vertex
#import "../logic/limits.typ": compute-primitive-limits
#import "@preview/tiptoe:0.3.1"
/// Draws a line into the data area.
/// ```example
/// #lq.diagram(
/// width: 3cm, height: 3cm,
/// lq.line((1, 2), (5, 4))
/// )
/// ```
/// The coordinates can also be given relative to the data area
/// (`0%` means at the very left/top and `100%` at the very right/bottom)
/// ```example
/// #lq.diagram(
/// width: 3cm, height: 3cm,
/// lq.line((0%, 0%), (100%, 100%))
/// )
/// ```
/// or mixed between relative, absolute and data values.
/// ```example
/// #lq.diagram(
/// width: 3cm, height: 3cm,
/// lq.line(
/// stroke: (paint: blue, dash: "dashed"),
/// (1, 100%), (4, 10pt)
/// )
/// )
/// ```
///
/// Arrow tips and tails can be added to a line through the arguments `tip` and `toe`.
/// This is supported via the Typst package
/// #link("https://typst.app/universe/package/tiptoe")[tiptoe].
/// ```example
/// #import "@preview/tiptoe:0.3.1"
/// #let xs = lq.arange(-5, 6)
///
/// #lq.diagram(
/// lq.plot(xs, xs.map(x => x*x)),
/// lq.line(
/// tip: tiptoe.stealth,
/// toe: tiptoe.bar,
/// (0, 10), (4, 16)
/// )
/// )
/// ```
///
#let line(
/// The start point of the line. Coordinates can be given as
/// - data coordinates (`int` or `float`),
/// - or absolute coordinates from the top left corner of the data area
/// (`length`),
/// - or in percent relative to the data area (`ratio`),
/// - or a combination of the latter two (`relative`).
/// -> array
start,
/// The end point of the line, see @line.start.
/// -> array
end,
/// How to stroke the line.
/// -> stroke
stroke: stroke(),
/// The legend label for this plot. See @plot.label.
/// -> content
label: none,
/// Places an arrow tip on the line. This expects a mark as specified by
/// the #link("https://typst.app/universe/package/tiptoe")[tiptoe package].
/// -> none | tiptoe.mark
tip: none,
/// Places an arrow tail on the line. This expects a mark as specified by
/// the #link("https://typst.app/universe/package/tiptoe")[tiptoe package].
/// -> none | tiptoe.mark
toe: none,
/// Whether to clip the plot to the data area. See @plot.clip.
/// -> bool
clip: true,
/// Determines the $z$ position of this plot in the order of rendered diagram objects.
/// See @plot.z-index.
/// -> int | float
z-index: 2,
) = {
let vertices = (start, end)
(
plot: (plot, transform) => {
if "make-legend" in plot {
return std.line(length: 100%, stroke: stroke)
}
let (start, end) = vertices.map(convert-vertex.with(transform: transform))
if tip == none and toe == none {
return place(std.line(
stroke: stroke,
start: start, end: end
))
}
place(tiptoe.line(
stroke: stroke,
start: start, end: end,
tip: tip, toe: toe
))
},
xlimits: compute-primitive-limits.with(vertices.map(x => x.at(0))),
ylimits: compute-primitive-limits.with(vertices.map(x => x.at(1))),
label: label,
legend: true,
clip: clip,
z-index: z-index
)
}

View File

@@ -0,0 +1,204 @@
#import "../assertations.typ"
#import "../logic/limits.typ": compute-primitive-limits
#import "../logic/process-coordinates.typ": convert-bezier-curve, transform-point
#import "../math.typ": vec
#import "@preview/tiptoe:0.3.1"
#let path-to-curve(
..vertices,
closed: false
) = {
vertices = vertices.pos()
if vertices.len() == 0 { return }
let is-vertex(v) = type(v) == array and type(v.first()) != array
let extract-vertex(v) = {
if is-vertex(v) { v }
else { v.first() }
}
let curve-elements = ()
let start-in = none
let out = none
for vertex in vertices {
let v = extract-vertex(vertex)
if is-vertex(vertex) {
if out == none {
curve-elements.push((v,))
} else {
curve-elements.push((out, none, v))
out = none
}
} else {
if vertex.len() == 2 {
vertex.push(vec.multiply(vertex.at(1), -1))
// now its definitely a "cubic"!
}
if curve-elements.len() == 0 {
curve-elements.push((v,))
start-in = vec.add(v, vertex.at(1))
} else if out == none {
curve-elements.push((auto, vec.add(v, vertex.at(1)), v))
out = auto
} else {
curve-elements.push((out, vec.add(v, vertex.at(1)), v))
}
out = vec.add(v, vertex.at(2))
}
}
let to-curve-element(x) = {
if x.len() == 1 { curve.line(..x) }
else if x.len() == 2 { curve.quad(..x) }
else if x.len() == 3 { curve.cubic(..x) }
}
curve-elements = curve-elements.map(to-curve-element)
let start = extract-vertex(vertices.first())
if closed {
if out != none or start-in != none {
curve-elements.push(curve.cubic(out, start-in, start))
}
curve-elements.push(curve.close(mode: "straight"))
}
(
curve.move(start),
..curve-elements,
)
}
/// Draws a path into the data area. Each vertex may be given as data coordinates,
/// as percentage relative to the data area or in absolute lengths (see @rect).
///
/// ```example
/// #lq.diagram(
/// height: 3.8cm,
/// width: 4cm,
/// lq.path(
/// ((0, 1), (0, -1)),
/// ((.5, 1), (0, 1)),
/// ((0,-1), (0, 1), (0, 1)),
/// ((-.5, 1), (0, -1)),
/// ((0, 1), (0, 1)),
/// stroke: red + 2pt
/// )
/// )
/// ```
///
#let path(
/// Vertices and curve elements. See the Typst built-in `path` function.
/// -> array
..vertices,
/// How to fill the path.
/// -> none | color | gradient | tiling
fill: none,
/// How to stroke the path.
/// -> auto | none | stroke
stroke: auto,
/// Whether to close the path.
/// -> bool
closed: false,
/// Places an arrow tip on the curve. This expects a mark as specified by
/// the #link("https://typst.app/universe/package/tiptoe")[tiptoe package].
/// -> none | tiptoe.mark
tip: none,
/// Places an arrow tail on the curve. This expects a mark as specified by
/// the #link("https://typst.app/universe/package/tiptoe")[tiptoe package].
/// -> none | tiptoe.mark
toe: none,
/// The legend label for this plot. See @plot.label.
/// -> content
label: none,
/// Whether to clip the plot to the data area. See @plot.clip.
/// -> bool
clip: true,
/// Determines the $z$ position of this plot in the order of rendered diagram objects.
/// See @plot.z-index.
/// -> int | float
z-index: 2,
) = {
assertations.assert-no-named(vertices, fn: "path")
let sub(p, q) = (p.at(0) - q.at(0), p.at(1) - q.at(1))
let add(p, q) = (p.at(0) + q.at(0), p.at(1) + q.at(1))
vertices = vertices.pos()
let all-points = vertices.enumerate().map(((i, v)) => {
if type(v.at(0)) != array { return (v,) }
if v.len() == 3 {
return (v.at(0),) + v.slice(1).map(c => {
if not c.map(type).all(x => x in (int, float)) { return v.at(0) }
add(v.at(0), c)
})
}
let vs = (v.at(0),)
if i != 0 and v.at(1).map(type).all(x => x in (int, float)){
vs.push(add(v.at(0), v.at(1)))
}
if i != vertices.len() - 1 {
// vs.push(sub(v.at(0), v.at(1)))
}
return vs
}).join()
// vertices = all-points
// let all-points = vertices
(
vertices: vertices,
plot: (plot, transform) => {
if "make-legend" in plot {
return std.rect(
width: 100%, height: 100%,
fill: fill, stroke: stroke
)
}
let new-vertices = vertices.map(v => {
if type(v.at(0)) != array { return transform-point(..v, transform) }
return convert-bezier-curve(v, transform)
let p = transform-point(..v.at(0), transform)
let cs = v.slice(1).map(c => {
let cabs = add(v.at(0), c)
let cabs = c
sub(transform-point(..cabs, transform), p)
})
(p,) + cs
})
let segments = path-to-curve(
closed: closed,
..new-vertices
)
let curve = std.curve
if tip != none or toe != none {
curve = tiptoe.curve.with(tip: tip, toe: toe)
}
place(
curve(
..segments,
fill: fill, stroke: stroke,
)
)
},
xlimits: compute-primitive-limits.with(all-points.map(x => x.at(0))),
ylimits: compute-primitive-limits.with(all-points.map(x => x.at(1))),
label: label,
legend: true,
clip: clip,
z-index: z-index
)
}

View File

@@ -0,0 +1,114 @@
#import "../process-styles.typ": twod-ify-alignment
#import "../logic/process-coordinates.typ": transform-point
/// Places any type of content in the data area. The coordinates of the origin
/// can be given as data coordinates, as percentage relative to the data area
/// or in absolute lengths (see @rect).
///
/// ```example
/// #lq.diagram(
/// lq.place(0.5, 0.5)[Hello],
/// lq.place(0%, 0.3, align: left)[
/// Text placed at `x: 0%` and `y: 0.3`
/// ]
/// )
/// ```
/// When annotating data points, it can be particularly useful to use the built-in
/// #link("https://typst.app/docs/reference/layout/pad/")[`pad`] function to
/// add some space.
/// ```example
/// #lq.diagram(
/// lq.plot((1, 2, 3, 4), (3, 5, 1, 3)),
/// lq.place(2, 5, align: left, pad(.7em)[max]),
/// lq.place(3, 1, align: right, pad(.7em)[min])
/// )
/// ```
///
///
/// Unlike other plotting commands, @place is not clipped to the data area by
/// default and the z-index is higher, making the placed content appear
/// on top of most other diagram objects.
///
/// ```example
/// #lq.diagram(
/// lq.plot((1, 2, 3), (1, 2.1, 3)),
/// lq.place(
/// 10%, 0%,
/// align: left,
/// box(fill: yellow, inset: 2pt)[
/// Here comes the plot🎶 ↓
/// ]
/// )
/// )
/// ```
/// Clipping can be activated via @place.clip and the z-index can be changed
/// through @place.z-index.
///
///
/// With @place, a smaller plot can also be placed within another plot.
///
/// ```example
/// #let xs = lq.linspace(-5, 5, num: 20)
///
/// #lq.diagram(
/// lq.plot((1,2,3), (1,2,3)),
/// lq.place(25%, 50%,
/// lq.diagram(
/// title: [mini],
/// fill: white,
/// width: 40pt, height: 30pt,
/// lq.plot(xs, xs.map(x => x*x))
/// )
/// )
/// )
/// ```
///
#let place(
/// The $x$ coordinate of the origin.
/// -> float | relative
x,
/// The $y$ coordinate of the origin.
/// -> float | relative
y,
/// The content to place in the data area.
/// -> any
body,
/// How to align the content at the origin coordinates.
/// -> alignment
align: center + horizon,
/// Whether to clip the plot to the data area. See @plot.clip.
/// -> bool
clip: false,
/// Determines the $z$ position of the content in the order of rendered diagram
/// objects. See @plot.z-index.
/// -> int | float
z-index: 21,
) = {
(
x: x,
y: y,
align: align,
body: body,
plot: (plot, transform) => {
let (px, py) = transform-point(x, y, transform)
std.place(
dx: px, dy: py,
std.place(twod-ify-alignment(align), body),
)
},
id: "place",
xlimits: () => none,
ylimits: () => none,
label: none,
clip: clip,
z-index: z-index
)
}

View File

@@ -0,0 +1,495 @@
#import "../algorithm/bezier-interpolation.typ": bezier-splines
#import "../logic/limits.typ": plot-lim
#import "../process-styles.typ": merge-strokes, merge-fills
#import "../assertations.typ"
#import "../logic/process-coordinates.typ": filter-nan-points, stepify
#import "../utility.typ": if-auto
#import "../style/styling.typ": mark, prepare-mark, prepare-path
#import "../model/errorbar.typ": errorbar
#import "../logic/time.typ"
#let get-errorbar-stroke(base-stroke) = {
if base-stroke == auto { return stroke() }
if base-stroke == none { return stroke() }
return stroke()
return stroke(thickness: base-stroke.thickness, paint: base-stroke.paint)
}
// Process error inputs of the form as documented in @plot.xerr.
#let process-errors(err, n /* basically x.len() */, kind: "x") = {
if type(err) in (int, float) {
err = (p: (err,) * n, m: (err,) * n)
} else if type(err) == dictionary {
assert(
"m" in err and "p" in err,
message: "Error bar dictionaries must contain both \"p\" and \"m\""
)
if err.keys().len() != 2 {
let key = err.keys().filter(x => x not in ("m", "p")).first()
assert(
false,
message: "Errorbar dictionary contains unexpected key \"" + key + "\", expected \"p\" and \"m\""
)
}
if type(err.m) in (int, float) {
err.m = (err.m,) * n
} else if type(err.m) == array {
assert(
err.m.len() == n,
message: "The length of `" + kind + "err.m` does not match the number of data points"
)
} else {
assert(false, message: "`" + kind + "err.m` expects a float or an array")
}
if type(err.p) in (int, float) {
err.p = (err.p,) * n
} else if type(err.p) == array {
assert(
err.p.len() == n,
message: "The length of `" + kind + "err.p` does not match the number of data points"
)
} else {
assert(false, message: "`" + kind + "err.p` expects a float or an array")
}
} else if type(err) == array {
assert(
err.len() == n,
message: "The length of `" + kind + "err` (" + str(err.len()) + ") does not match the number of data points"
)
err = err.map(e => {
if type(e) in (int, float) { (p: e, m: e) }
else if type(e) == dictionary {
assert("p" in e and "m" in e, message: "Errorbar dictionaries must contain both \"m\" and \"p\"")
e
}
else { assert(false, message: "Expected a single uncertainty or a dictionary, found " + repr(e))}
})
err = (p: err.map(e => e.p), m: err.map(e => e.m))
} else {
assert(
false,
message: "`" + kind + "err` expects a float, an array, or a dictionary with the keys \"p\" and \"m\"."
)
}
err
}
#let render-plot(plot, transform) = {
let (points, runs) = filter-nan-points(plot.x.zip(plot.y), generate-runs: true)
if "make-legend" in plot {
runs = (((0, 0.5), (1, 0.5)),)
points = ((0.5, 0.5),)
if plot.yerr != none { plot.yerr = (p: (.5,), m: (.5,),) }
if plot.xerr != none { plot.xerr = (p: (.25,), m: (.25,)) }
plot.mark.every = none
}
let line = merge-strokes(plot.style.stroke, plot.style.color)
if line != none {
show: prepare-path.with(
stroke: line,
element: curve
)
let (step, smooth) = (plot.style.step, plot.style.smooth)
if step != none and smooth {
panic("`step` and `smooth` are mututally exclusive")
}
for run in runs {
if run.len() == 0 { continue }
if step != none { run = stepify(run, step: step )}
run = run.map(p => transform(..p))
if run.len() > 2 and smooth {
let x = run.map(((x, _)) => x)
let y = run.map(((_, y)) => y)
let points = bezier-splines(x, y)
place(curve(curve.move(points.at(0)), ..points
.slice(1)
.chunks(3)
.map(p => curve.cubic(..p))))
} else {
place(curve(
curve.move(run.first()),
..run.slice(1).map(curve.line)
))
}
}
}
let errorbar-stroke = prepare-path.with(
stroke: merge-strokes(
..((dash: "solid"), plot.style.stroke, plot.style.color).filter(x => x != none)
)
)
let filter = {
let every = plot.mark.every
if every == 1 { none }
else if type(every) == int {
let n = plot.x.len()
arr => range(calc.quo(n, every)).map(i => arr.at(i * every))
} else if type(every) == dictionary {
assertations.assert-dict-keys(every, mandatory: ("n",), optional: ("start", "end"))
let size = plot.x.len()
let start = every.at("start", default: 0)
let end = every.at("end", default: size + 1)
if end < 0 { end = size + end }
assert(start >= 0 and start < size, message: "Invalid start index " + str(start))
arr => range(calc.quo(end - start, every.n))
.map(i => arr.at(i * every.n + start))
} else if type(every) == array {
arr => every.map(i => arr.at(i))
}
}
if filter != none { points = filter(points) }
if plot.xerr != none {
show: errorbar-stroke
let (p, m) = plot.xerr
if filter != none {
p = filter(p)
m = filter(m)
}
points.zip(p, m).map((((x, y), p, m)) => {
let (p0, p1) = (transform(x - m, y), transform(x + p, y))
place(
dx: p0.at(0),
dy: p0.at(1),
box(width: p1.at(0) - p0.at(0), errorbar(kind: "x"))
)
}).join()
}
if plot.yerr != none {
show: errorbar-stroke
let (p, m) = plot.yerr
if filter != none {
p = filter(p)
m = filter(m)
}
points.zip(p, m).map((((x, y), p, m)) => {
let (p0, p1) = (transform(x, y - m), transform(x, y + p))
let (y0, y1) = (p0.at(1), p1.at(1)).sorted()
place(
dx: p1.at(0),
dy: y0,
box(height: y1 - y0, errorbar(kind: "y"))
)
}).join()
}
show: prepare-mark.with(
func: plot.mark.mark,
color: plot.style.color,
fill: plot.mark.fill,
size: plot.mark.size
)
let marker = mark()
let transformed-points = points.map(p => transform(..p))
transformed-points.map(((x, y)) => place(dx: x, dy: y, marker)).join()
}
/// Standard plotting method for 2d data with lines and/or marks and optional
/// error bars. Points where the $x$ or $y$ coordinate is `nan` are skipped.
///
/// ```example
/// #let x = lq.linspace(0, 10)
///
/// #lq.diagram(
/// lq.plot(x, x => calc.sin(x + 0.541))
/// )
/// ```
/// The $y$ coordinates can be given either as an array or as a function to be
/// evaluated for all $x$ coordinates.
///
/// By default, the line and mark style is determined by the current
/// @diagram.cycle. However, they can be configured per plot with the options
/// @plot.color, @plot.mark,
/// and @plot.stroke.
///
/// This function is also intended for creating plots with error bars.
/// Error bars can be styled through the @errorbar type.
///
/// ```example
/// #lq.diagram(
/// lq.plot(
/// range(8), (3, 6, 2, 6, 5, 9, 0, 4),
/// yerr: (1, 1, .7, .8, .2, .6, .5, 1),
/// stroke: none,
/// mark: "star",
/// mark-size: 6pt
/// )
/// )
/// ```
#let plot(
/// An array of $x$ coordinates. Data coordinates need to be of type `int` or `float`.
/// -> array
x,
/// Specifies either an array of $y$ coordinates or a function that takes an
/// `x` value and returns a corresponding `y` coordinate. The number of $x$
/// and $y$ coordinates must match.
/// -> array | function
y,
/// Optional errors/uncertainties for $x$ coordinates. Symmetric errors can
/// be specified as a
/// - a constant (e.g., `xerr: 1.5`) or
/// - an array with the same length as @plot.x for individual errors per
/// data point (e.g., `xerr: (0.5, 1, 1.5)`).
///
/// Asymmetric errors can be given as
/// - a dictionary with the keys `p` (plus) and `m` (minus) with either a
/// constant value or arrays with the same length as @plot.x (e.g.,
/// `xerr: (p: 1, m: 2)`) or
/// - an array of dictionaries per data point, each filled with single `p`
/// and `m` values (e.g., `xerr: ((p: 1, m: 2), (p: 2, m: 3))`).
///
/// The look of the error bars can be controlled through the type @errorbar.
/// -> none | array | dictionary
xerr: none,
/// Optional errors/uncertainties for $y$ coordinates. See @plot.xerr.
///
/// The look of the error bars can be controlled through @errorbar.
/// -> none | array
yerr: none,
/// Combined color for line and marks. See also the parameters @plot.stroke and
/// @plot.mark-fill which take precedence over `color`, if set.
/// -> auto | color
color: auto,
/// The line style to use for this plot (takes precedence over @plot.color).
/// -> auto | stroke
stroke: auto,
/// The mark to use to mark data points. This may either be a mark (such as
/// `lq.mark.x`) or a registered mark string, see @mark.
/// -> auto | none | lq.mark | string
mark: auto,
/// Size of the marks. For variable-size mark plots, use the plot type @scatter.
/// -> auto | length
mark-size: auto,
/// Color of the marks (takes precedence over @plot.color).
/// TODO: this parameter should eventually be removed. Instead one
/// would be able to set mark color and stroke through
/// ```
/// #lq.diagram(
/// plot(..), // normal plot
/// {
/// set mark(fill: red, stroke: black)
/// plot(..)
/// }
///)
/// ```
/// This again reduces the API. `mark.size` however is common enough to deserve
/// its own parameter.
/// -> auto | color
mark-color: auto,
/// Step mode for plotting the lines.
/// - `none`: Consecutive data points are connected with a straight line.
/// - `start`: The interval $(x_{i-1}, x_i]$ takes the value of $x_i$.
/// - `center`: The value switches half-way between consecutive $x$ positions.
/// - `end`: The interval $[x_i, x_{i+1})$ takes the value of $x_i$.
///
/// #details[
/// ```example
/// #import lilaq
///
/// #lq.diagram(
/// lq.plot(
/// range(8), (3,6,2,6,5,9,0,4),
/// step: center
/// )
/// )
/// ```
/// ]
/// -> none | start | end | center
step: none,
/// Interpolates the data set using Bézier splines instead of connecting the points with straight lines.
///
/// Note: If two or fewer points are given, linear interpolation is used.
///
/// #details[
/// ```example
/// #import lilaq
/// #lq.diagram(
/// lq.plot(
/// range(8), (3, 6, 2, 6, 5, 9, 0, 4),
/// smooth: true
/// )
/// )
/// ```
/// ]
///
/// -> bool
smooth: false,
/// Specifies the interval of marks to plot. This can be used to skip
/// marks while still drawing lines between all points.
/// - `none`: All marks are plotted.
/// - `int`: Every $n$-th mark is plotted.
/// - `array`: All marks with the given indices are plotted.
/// - `dict`: A dictionary with the keys `n`, `start` (start index), and
/// `end` (end index, negative indices count from the end) can be used
/// to specify a range of marks to plot.
///
/// #details[
/// ```example
/// #lq.diagram(
/// lq.plot(
/// range(20),
/// range(20).map(x => x*x),
/// every: 4
/// )
/// )
/// ```
/// ]
/// -> none | int | array | dict
every: none,
/// The legend label for this plot. If not given, the plot will not appear in the
/// legend.
/// -> any
label: none,
/// Whether to clip the plot to the data area. This is usually a good idea for plots
/// with lines but it does also clip part of marks that lie right on an axis.
/// #details[
/// Comparison between clipped and non-clipped plot.
/// ```example
/// #lq.diagram(
/// margin: 0%,
/// lq.plot(
/// (1, 2, 3), (2.5, 1.9, 1.5),
/// mark: "o",
/// ),
/// lq.plot(
/// (1, 2, 3), (1, 2.1, 3),
/// mark: "o",
/// clip: false
/// )
/// )
/// ```
/// ]
/// -> bool
clip: true,
/// Specifies the $z$ position of this plot in the order of rendered diagram
/// objects. This makes it also possible to render plots in front of the axes
/// which have a z-index of `20`.
/// #details[
/// In this example, the points are listed before the bars in the legend but
/// they are still drawn in front of the bars.
/// ```example
/// #lq.diagram(
/// legend: (position: bottom),
/// lq.plot(
/// (1, 2, 3, 4), (2, 3, 4, 5),
/// mark-size: 10pt,
/// z-index: 2.01,
/// label: [Points],
/// ),
/// lq.bar(
/// (1, 2, 3, 4), (2, 3, 4, 5),
/// label: [Bars],
/// )
/// )
/// ```
/// ]
/// -> int | float
z-index: 2,
) = {
if type(y) == function {
y = x.map(y)
}
let datetime-axes = (:)
if type(x.at(0, default: 0)) == datetime {
x = time.to-seconds(..x)
datetime-axes.x = true
}
if type(y.at(0, default: 0)) == datetime {
y = time.to-seconds(..y)
datetime-axes.y = true
}
assertations.assert-matching-data-dimensions(x, y, fn-name: "plot")
assert(step in (none, start, end, center))
if xerr != none { xerr = process-errors(xerr, x.len(), kind: "x") }
if yerr != none { yerr = process-errors(yerr, x.len(), kind: "y") }
(
x: x,
y: y,
xerr: xerr,
yerr: yerr,
label: label,
mark: (
mark: mark,
fill: mark-color,
size: mark-size,
every: every
),
style: (
stroke: stroke,
color: color,
step: step,
smooth: smooth
),
plot: render-plot,
xlimits: () => plot-lim(x, err: xerr),
ylimits: () => plot-lim(y, err: yerr),
datetime: datetime-axes,
legend: true,
ignores-cycle: false,
clip: clip,
z-index: z-index
)
}

View File

@@ -0,0 +1,303 @@
#import "../assertations.typ"
#import "../logic/sample-colors.typ": sample-colors
#import "../process-styles.typ": merge-strokes
#import "../math.typ": vec, mesh
#import "../utility.typ": if-auto, match, match-type
#import "../style/styling.typ": style
#import "@preview/tiptoe:0.3.1"
#let render-quiver(plot, transform) = context {
let arrow = plot.style.arrow
let get-arrow-stroke = plot.style.get-arrow-stroke
if "make-legend" in plot {
return arrow(
length: 100%,
stroke: get-arrow-stroke(0, 1)
)
}
let pivot = match(plot.style.pivot,
center, () => dir => dir.map(x => -x*0.5),
start, () => dir => (0, 0),
end, () => dir => dir.map(x => -x),
default: () => assert(false, message: "The argument `pivot` of `quiver` needs to be one of 'start', 'center', or 'end'")
)
for i in range(plot.x.len()) {
for j in range(plot.y.len()) {
let dir = plot.directions.at(j).at(i)
if dir.any(float.is-nan) { continue }
let x = plot.x.at(i)
let y = plot.y.at(j)
dir = vec.multiply(dir, plot.scale)
let length = calc.sqrt(dir.map(x => x*x).sum())
let start = vec.add((x, y), pivot(dir))
let end = vec.add(start, dir)
let stroke = get-arrow-stroke(i + j*plot.x.len(), length)
if length == 0 {
let (x, y) = transform(..start)
let radius = plot.style.thickness / 2
place(
dx: x - radius, dy: y - radius,
circle(radius: radius, fill: stroke.paint)
)
continue
}
place(
arrow(
start: transform(..start),
end: transform(..end),
stroke: stroke
)
)
}
}
}
/// Creates a quiver plot for visualizing vector fields over a rectangular
/// coordinate grid.
///
/// The `quiver` function takes an array of $x$- and $y$-coordinates as well as
/// a two-dimensional array of vector directions or a mapper from `(x, y)`
/// pairs to direction vectors.
/// ```example
/// #lq.diagram(
/// lq.quiver(
/// lq.arange(-2, 3),
/// lq.arange(-2, 3),
/// (x, y) => (x + y, y - x)
/// )
/// )
/// ```
/// By default, arrow lengths and strokes are scaled automatically to
/// compensate for the density of a quiver plot.
///
/// On top, the arrows can easily be color-coded, similar to @colormesh,
/// see @quiver.color.
#let quiver(
/// A one-dimensional array of $x$ data coordinates.
/// -> array
x,
/// A one-dimensional array of $y$ data coordinates.
/// -> array
y,
/// Direction vectors for the arrows.
/// This can either be
/// - a two-dimensional array of dimensions $m×n$ where $m$ is the length
/// of @quiver.x and $n$ is the length of @quiver.y,
/// - or a function that takes two arguments `(x, y)` and returns a
/// two-dimensional direction vector.
///
/// Masking is possible through `nan` values.
/// -> array | function
directions,
/// How to stroke the arrows. This parameter takes precedence over
/// @quiver.color. If the stroke thickness is left at `auto`, small
/// arrows will be drawn with a thinner line style.
/// -> auto | stroke
stroke: auto,
/// Scales the length of the arrows uniformly. If set to `auto`, the length
/// is heuristically computed from the density of the coordinate grid.
/// -> auto | int | float
scale: auto,
/// With which part the arrows should point onto the grid coordinates, e.g.,
/// when set to `end`, the tip (end) of the arrow will point to the
/// respective coordinates.
///
/// #details[
/// ```example
/// #show: lq.set-diagram(
/// xaxis: (tick-distance: 1),
/// xlim: (-3, 3),
/// ylim: (-3, 3),
/// width: 4cm
/// )
///
/// #let x = lq.arange(-2, 3)
/// #let y = lq.arange(-2, 3)
/// #let directions = lq.mesh(x, y, (x, y) => (x + y, y - x))
///
/// #lq.diagram(
/// title: [`pivot: end`],
/// lq.quiver(x, y, directions, pivot: end),
/// )
/// #lq.diagram(
/// title: [`pivot: center` (default)],
/// lq.quiver(x, y, directions, pivot: center),
/// )
/// #lq.diagram(
/// title: [`pivot: start`],
/// lq.quiver(x, y, directions, pivot: start),
/// )
/// ```
/// ]
/// -> start | center | end
pivot: end,
/// Determines the arrow tip to use. This expects a mark as specified by
/// the #link("https://typst.app/universe/package/tiptoe")[tiptoe package].
/// -> none | tiptoe.mark
tip: tiptoe.stealth.with(length: 400%),
/// Determines the arrow tail to use. This expects a mark as specified by
/// the #link("https://typst.app/universe/package/tiptoe")[tiptoe package].
/// -> none | tiptoe.mark
toe: none,
/// How to color the arrows. This can be a single color or a two-dimensional
/// array with the same dimensions as @quiver or a function that receives
/// `(x, y, u, v)` tuples of `x` and `y` coordinates and corresponding
/// direction components and returns a scalar (`float`) or `color`.
///
/// #details[
/// In this example, we make a color-coding by taking the length of the
/// direction vectors.
/// ```example
/// #lq.diagram(
/// lq.quiver(
/// lq.arange(-2, 3),
/// lq.arange(-2, 3),
/// (x, y) => (x + 2 * y, y - x),
/// color: (x, y, u, v) => calc.norm(u, v),
/// )
/// )
/// ```
/// ]
/// -> color | array | function
color: black,
/// A color map to sample from. The color map can be given in form of a
/// gradient or an array of colors.
/// -> array | gradient
map: color.map.viridis,
/// Sets the data value that corresponds to the first color of the color map.
/// If set to `auto`, it defaults to the minimum color value.
/// -> auto | int | float
min: auto,
/// Sets the data value that corresponds to the last color of the color map.
/// If set to `auto`, it defaults to the maximum color value.
/// -> auto | int | float
max: auto,
/// The normalization method used to scale @quiver.color scalars to the range
/// $[0,1]$ before mapping them to colors using the color map. This can be a
/// @scale, a string that is the identifier of a built-in scale or a function
/// that takes one argument.
/// -> lq.scale | str | function
norm: "linear",
/// The legend label for this plot. See @plot.label.
/// -> content
label: none,
/// Determines the $z$ position of this plot in the order of rendered diagram
/// objects. See @plot.z-index.
/// -> int | float
z-index: 2
) = {
if type(directions) == function {
directions = mesh(x, y, directions)
}
if type(color) == function {
color = mesh(
x.enumerate(), y.enumerate(),
((i, x), (j, y)) => color(x, y, ..directions.at(j).at(i))
)
}
let cinfo
if type(color) == array {
assert(type(color.first()) == array, message: "The argument `color` for `quiver` either needs to be a single value or a 2D array")
let color-flat = color.flatten()
assert(x.len() * y.len() == color-flat.len(), message: "The number of elements in `color` does not match the grid for `quiver()`")
if type(color-flat.at(0, default: 0)) in (int, float) {
(color, cinfo) = sample-colors(color-flat, map, norm, ignore-nan: true, min: min, max: max)
}
}
if scale == auto {
let lengths = directions.join().map(a => calc.sqrt(a.map(b => b*b).sum()))
lengths = lengths.filter(x => not float.is-nan(x))
let n = lengths.len()
let average = lengths.sum() / n
let compensation = calc.max(4., calc.pow(n, .6) * .6)
scale = 1 / (0.54 * average * compensation)
}
if stroke != auto { stroke = std.stroke(stroke) }
let thickness = match-type(
stroke,
stroke: () => if-auto(stroke.thickness, 1pt),
auto-type: 1pt
)
let get-stroke = {
if stroke != auto and stroke.thickness != auto {
let p = stroke
(color, length) => merge-strokes(stroke, color)
} else {
(color, length) => merge-strokes(
1pt*calc.clamp(4 * length, .2, 1),
stroke, color
)
}
}
let get-arrow-stroke = match-type(
color,
panic: true,
color: () => (i, len) => get-stroke(color, len),
array: () => (i, len) => get-stroke(color.at(i), len),
)
let arrow = tiptoe.line.with(tip: tip, toe: toe)
(
cinfo: cinfo,
x: x,
y: y,
directions: directions,
scale: scale,
label: label,
style: (
thickness: thickness,
pivot: pivot,
color: color,
arrow: arrow,
get-arrow-stroke: get-arrow-stroke
),
plot: render-quiver,
xlimits: () => (x.at(0), x.at(-1)),
ylimits: () => (y.at(0), y.at(-1)),
legend: true,
z-index: z-index
)
}

Some files were not shown because too many files have changed in this diff Show More