code-node-graph 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_node/__init__.py +40 -0
- code_node/__main__.py +7 -0
- code_node/analysis/__init__.py +3 -0
- code_node/analysis/aws_model.py +197 -0
- code_node/analysis/findings.py +462 -0
- code_node/analysis/graph.py +780 -0
- code_node/analysis/mapping.py +114 -0
- code_node/analysis/model.py +289 -0
- code_node/analysis/reader.py +1926 -0
- code_node/analysis/syntax.py +220 -0
- code_node/analysis/terms.py +505 -0
- code_node/cli.py +316 -0
- code_node/deploys/__init__.py +321 -0
- code_node/deploys/sam.py +371 -0
- code_node/deploys/serverless.py +117 -0
- code_node/kit/__init__.py +4 -0
- code_node/kit/blocks.py +184 -0
- code_node/kit/fmt.py +389 -0
- code_node/kit/highlight.py +131 -0
- code_node/kit/render.py +1066 -0
- code_node/kit/text.py +81 -0
- code_node/libraries/__init__.py +333 -0
- code_node/libraries/llm.py +124 -0
- code_node/libraries/mongo.py +169 -0
- code_node/libraries/redis.py +253 -0
- code_node/libraries/sql.py +232 -0
- code_node/pages.py +92 -0
- code_node/platforms/__init__.py +203 -0
- code_node/platforms/aws_lambda.py +290 -0
- code_node/platforms/web.py +301 -0
- code_node/project.py +402 -0
- code_node/report/__init__.py +3 -0
- code_node/report/brands.py +244 -0
- code_node/report/build.py +119 -0
- code_node/report/editor.py +40 -0
- code_node/report/graph.py +1411 -0
- code_node/report/logos/README.md +8 -0
- code_node/report/logos/anthropic-icon.svg +1 -0
- code_node/report/logos/aws-amplify.svg +1 -0
- code_node/report/logos/aws-api-gateway.svg +1 -0
- code_node/report/logos/aws-appflow.svg +1 -0
- code_node/report/logos/aws-appsync.svg +1 -0
- code_node/report/logos/aws-athena.svg +1 -0
- code_node/report/logos/aws-backup.svg +1 -0
- code_node/report/logos/aws-batch.svg +1 -0
- code_node/report/logos/aws-certificate-manager.svg +1 -0
- code_node/report/logos/aws-cloudformation.svg +1 -0
- code_node/report/logos/aws-cloudfront.svg +1 -0
- code_node/report/logos/aws-cloudtrail.svg +1 -0
- code_node/report/logos/aws-cloudwatch.svg +1 -0
- code_node/report/logos/aws-codebuild.svg +1 -0
- code_node/report/logos/aws-codecommit.svg +1 -0
- code_node/report/logos/aws-codedeploy.svg +1 -0
- code_node/report/logos/aws-codepipeline.svg +1 -0
- code_node/report/logos/aws-cognito.svg +1 -0
- code_node/report/logos/aws-config.svg +1 -0
- code_node/report/logos/aws-documentdb.svg +1 -0
- code_node/report/logos/aws-dynamodb.svg +1 -0
- code_node/report/logos/aws-ec2.svg +1 -0
- code_node/report/logos/aws-ecs.svg +1 -0
- code_node/report/logos/aws-eks.svg +1 -0
- code_node/report/logos/aws-elasticache.svg +1 -0
- code_node/report/logos/aws-elb.svg +1 -0
- code_node/report/logos/aws-eventbridge.svg +1 -0
- code_node/report/logos/aws-glacier.svg +1 -0
- code_node/report/logos/aws-glue.svg +1 -0
- code_node/report/logos/aws-iam.svg +1 -0
- code_node/report/logos/aws-keyspaces.svg +1 -0
- code_node/report/logos/aws-kinesis.svg +1 -0
- code_node/report/logos/aws-kms.svg +1 -0
- code_node/report/logos/aws-lake-formation.svg +1 -0
- code_node/report/logos/aws-lambda.svg +1 -0
- code_node/report/logos/aws-lightsail.svg +1 -0
- code_node/report/logos/aws-mq.svg +1 -0
- code_node/report/logos/aws-msk.svg +1 -0
- code_node/report/logos/aws-neptune.svg +1 -0
- code_node/report/logos/aws-open-search.svg +1 -0
- code_node/report/logos/aws-quicksight.svg +1 -0
- code_node/report/logos/aws-rds.svg +1 -0
- code_node/report/logos/aws-redshift.svg +1 -0
- code_node/report/logos/aws-route53.svg +1 -0
- code_node/report/logos/aws-s3.svg +1 -0
- code_node/report/logos/aws-secrets-manager.svg +1 -0
- code_node/report/logos/aws-ses.svg +1 -0
- code_node/report/logos/aws-shield.svg +1 -0
- code_node/report/logos/aws-sns.svg +1 -0
- code_node/report/logos/aws-sqs.svg +1 -0
- code_node/report/logos/aws-step-functions.svg +1 -0
- code_node/report/logos/aws-systems-manager.svg +1 -0
- code_node/report/logos/aws-timestream.svg +1 -0
- code_node/report/logos/aws-waf.svg +1 -0
- code_node/report/logos/aws-xray.svg +1 -0
- code_node/report/logos/aws.svg +1 -0
- code_node/report/logos/azure-icon.svg +1 -0
- code_node/report/logos/django-icon.svg +1 -0
- code_node/report/logos/elasticsearch.svg +1 -0
- code_node/report/logos/fastapi-icon.svg +1 -0
- code_node/report/logos/firebase-icon.svg +1 -0
- code_node/report/logos/flask.svg +1 -0
- code_node/report/logos/google-cloud.svg +1 -0
- code_node/report/logos/kafka-icon.svg +1 -0
- code_node/report/logos/mariadb-icon.svg +1 -0
- code_node/report/logos/mongodb-icon.svg +1 -0
- code_node/report/logos/mysql-icon.svg +1 -0
- code_node/report/logos/openai-icon.svg +1 -0
- code_node/report/logos/postgresql.svg +1 -0
- code_node/report/logos/python.svg +1 -0
- code_node/report/logos/rabbitmq-icon.svg +1 -0
- code_node/report/logos/redis.svg +1 -0
- code_node/report/logos/serverless.svg +1 -0
- code_node/report/logos/slack-icon.svg +1 -0
- code_node/report/logos/small/aws-api-gateway.svg +1 -0
- code_node/report/logos/small/aws-appflow.svg +1 -0
- code_node/report/logos/small/aws-appsync.svg +1 -0
- code_node/report/logos/small/aws-athena.svg +1 -0
- code_node/report/logos/small/aws-backup.svg +1 -0
- code_node/report/logos/small/aws-batch.svg +1 -0
- code_node/report/logos/small/aws-certificate-manager.svg +1 -0
- code_node/report/logos/small/aws-cloudformation.svg +1 -0
- code_node/report/logos/small/aws-cloudfront.svg +1 -0
- code_node/report/logos/small/aws-cloudtrail.svg +1 -0
- code_node/report/logos/small/aws-cloudwatch.svg +1 -0
- code_node/report/logos/small/aws-codebuild.svg +1 -0
- code_node/report/logos/small/aws-codecommit.svg +1 -0
- code_node/report/logos/small/aws-codedeploy.svg +1 -0
- code_node/report/logos/small/aws-codepipeline.svg +1 -0
- code_node/report/logos/small/aws-cognito.svg +1 -0
- code_node/report/logos/small/aws-config.svg +1 -0
- code_node/report/logos/small/aws-documentdb.svg +1 -0
- code_node/report/logos/small/aws-dynamodb.svg +1 -0
- code_node/report/logos/small/aws-ec2.svg +1 -0
- code_node/report/logos/small/aws-ecs.svg +1 -0
- code_node/report/logos/small/aws-eks.svg +1 -0
- code_node/report/logos/small/aws-elasticache.svg +1 -0
- code_node/report/logos/small/aws-elb.svg +1 -0
- code_node/report/logos/small/aws-eventbridge.svg +1 -0
- code_node/report/logos/small/aws-glacier.svg +1 -0
- code_node/report/logos/small/aws-glue.svg +1 -0
- code_node/report/logos/small/aws-iam.svg +1 -0
- code_node/report/logos/small/aws-keyspaces.svg +1 -0
- code_node/report/logos/small/aws-kinesis.svg +1 -0
- code_node/report/logos/small/aws-kms.svg +1 -0
- code_node/report/logos/small/aws-lake-formation.svg +1 -0
- code_node/report/logos/small/aws-lambda.svg +1 -0
- code_node/report/logos/small/aws-mq.svg +1 -0
- code_node/report/logos/small/aws-msk.svg +1 -0
- code_node/report/logos/small/aws-neptune.svg +1 -0
- code_node/report/logos/small/aws-open-search.svg +1 -0
- code_node/report/logos/small/aws-quicksight.svg +1 -0
- code_node/report/logos/small/aws-rds.svg +1 -0
- code_node/report/logos/small/aws-redshift.svg +1 -0
- code_node/report/logos/small/aws-route53.svg +1 -0
- code_node/report/logos/small/aws-s3.svg +1 -0
- code_node/report/logos/small/aws-secrets-manager.svg +1 -0
- code_node/report/logos/small/aws-ses.svg +1 -0
- code_node/report/logos/small/aws-shield.svg +1 -0
- code_node/report/logos/small/aws-sns.svg +1 -0
- code_node/report/logos/small/aws-sqs.svg +1 -0
- code_node/report/logos/small/aws-step-functions.svg +1 -0
- code_node/report/logos/small/aws-systems-manager.svg +1 -0
- code_node/report/logos/small/aws-timestream.svg +1 -0
- code_node/report/logos/small/aws-waf.svg +1 -0
- code_node/report/logos/small/aws-xray.svg +1 -0
- code_node/report/logos/snowflake-icon.svg +1 -0
- code_node/report/logos/sqlite.svg +1 -0
- code_node/report/logos/stripe.svg +1 -0
- code_node/report/logos/supabase-icon.svg +1 -0
- code_node/report/logos/twilio-icon.svg +1 -0
- code_node/report/outline.py +219 -0
- code_node/report/style.py +358 -0
- code_node/report/tabs.py +73 -0
- code_node/report/walk.py +624 -0
- code_node/serve.py +277 -0
- code_node_graph-0.1.0.dist-info/METADATA +165 -0
- code_node_graph-0.1.0.dist-info/RECORD +178 -0
- code_node_graph-0.1.0.dist-info/WHEEL +4 -0
- code_node_graph-0.1.0.dist-info/entry_points.txt +2 -0
- code_node_graph-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,1411 @@
|
|
|
1
|
+
"""
|
|
2
|
+
_Graph: a CodeGraph drawn as an SVG of cards in columns, with arrows routed between them.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
import unicodedata
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
from ..kit.blocks import _Report, _draws
|
|
15
|
+
from ..kit.fmt import _plural
|
|
16
|
+
from ..kit.highlight import _python_lines
|
|
17
|
+
from ..kit.render import _prose
|
|
18
|
+
from ..kit.text import _esc
|
|
19
|
+
|
|
20
|
+
from ..analysis.graph import CodeGraph, GraphEdge, GraphNode
|
|
21
|
+
from .brands import AWS_LOGOS, SERVICE_LOGOS, fills, logo, small_logo
|
|
22
|
+
from .editor import editor_name, open_at
|
|
23
|
+
from .style import _ROOT
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class _Graph:
|
|
28
|
+
"""
|
|
29
|
+
how_it_works()'s graph: a CodeGraph drawn as boxes in columns joined by arrows. In HTML an SVG, where pointing
|
|
30
|
+
at a box leaves only what it's joined to in sight, and clicking one opens its code in a panel on the right (from
|
|
31
|
+
`sources`, {file: its text}; without them the boxes aren't links); in text each box with the arrows that leave it.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
graph: CodeGraph
|
|
35
|
+
title: str = ""
|
|
36
|
+
empty: str = "(nothing to draw)"
|
|
37
|
+
sources: dict[str, str] = field(default_factory=dict)
|
|
38
|
+
editor: str = "" # a link to open a line in an editor, from editor_link(): each place in a panel links to it
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_EDGE_WORDS = {
|
|
42
|
+
"call": "calls",
|
|
43
|
+
"event": "passes its input to",
|
|
44
|
+
"env": "configures",
|
|
45
|
+
"client": "makes the client for",
|
|
46
|
+
"http": "sends HTTP requests to",
|
|
47
|
+
"reads": "reads from",
|
|
48
|
+
"writes": "writes to",
|
|
49
|
+
"sends": "sends to",
|
|
50
|
+
"runs": "calls",
|
|
51
|
+
"signs": "signs links for",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# how_it_works()'s graph, drawn: cards with an icon badge per kind on a quiet dotted canvas, column captions, arrows that
|
|
56
|
+
# land on the operation (or leave from the variable) they're about, and a legend of pills. The _g names are its helpers.
|
|
57
|
+
_GRAPH_PAD_X = 28 # left and right of the drawing
|
|
58
|
+
|
|
59
|
+
_GRAPH_BAND = 52 # the captions' band at the top
|
|
60
|
+
|
|
61
|
+
_GRAPH_PAD_B = 32
|
|
62
|
+
|
|
63
|
+
_GRAPH_GAP, _GRAPH_GAP_EACH, _GRAPH_GAP_MAX = 84, 3, 150 # between columns: room for the arrows, wider for each arrow
|
|
64
|
+
# that crosses the gap, so a busy gap's curves stay gentle
|
|
65
|
+
|
|
66
|
+
_GRAPH_ROW_GAP = 34 # between boxes in a column
|
|
67
|
+
|
|
68
|
+
_GRAPH_HOP_GAP = 16 # between an arrow passing through a column and what's beside it
|
|
69
|
+
|
|
70
|
+
_GRAPH_MIN_W, _GRAPH_MAX_W, _GRAPH_MAX_IN, _GRAPH_MAX_HANDLER = 184, 284, 268, 304 # a column's width, from its boxes
|
|
71
|
+
|
|
72
|
+
_GRAPH_MAX_FLAGGED = 320 # ...or a column whose box has a line a warning is about ('ListObjectsV2 first page only')
|
|
73
|
+
|
|
74
|
+
_GRAPH_LABEL_LINE = 17 # a name too long for one line takes two
|
|
75
|
+
|
|
76
|
+
_GRAPH_HEAD, _GRAPH_HEAD_BARE = 56, 50 # a card's header (badge, name, what's under it), and without a line under it
|
|
77
|
+
|
|
78
|
+
_GRAPH_ROW = 19 # a listed line: a field, a variable, an operation
|
|
79
|
+
|
|
80
|
+
_GRAPH_PROSE = 17 # a line of prose: a docstring, what start-up does
|
|
81
|
+
|
|
82
|
+
_GRAPH_ROUTE = 28 # a web route's pill
|
|
83
|
+
|
|
84
|
+
_GRAPH_SWEEPS = 16 # passes that reorder each column by where its neighbours are, so fewer arrows cross; the order
|
|
85
|
+
# with the fewest crossings is kept
|
|
86
|
+
|
|
87
|
+
_GRAPH_COUNTED = 4000 # arrow segments, at most, whose crossings are counted to pick the best order (past that, the
|
|
88
|
+
# last pass's order, after _GRAPH_SWEEPS_BIG passes)
|
|
89
|
+
|
|
90
|
+
_GRAPH_SWEEPS_BIG = 6
|
|
91
|
+
|
|
92
|
+
_GRAPH_TALL, _GRAPH_LANES = 1800, 4 # a column taller than this is split into lanes side by side, at most this many
|
|
93
|
+
|
|
94
|
+
_GRAPH_ZOOMS = ( # the graph's zoom buttons: (key, word, said on hover); auto is checked to start
|
|
95
|
+
("auto", "Auto", "As big as it is, shrunk a little to fit the page; past that it scrolls"),
|
|
96
|
+
("fit", "Fit", "All of it in sight"),
|
|
97
|
+
("100", "100%", "As big as it is"),
|
|
98
|
+
("150", "150%", "Bigger"),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
_GRAPH_LISTS = ("event", "env", "aws", "service", "http") # boxes whose lines are names: fields, variables,
|
|
102
|
+
# operations, requests
|
|
103
|
+
|
|
104
|
+
# Arial / Helvetica advance widths (per 1000 em), what the system sans stack draws on most machines; mono is 0.6 em
|
|
105
|
+
_G_SANS = dict(
|
|
106
|
+
zip(
|
|
107
|
+
" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~",
|
|
108
|
+
[
|
|
109
|
+
278,
|
|
110
|
+
278,
|
|
111
|
+
355,
|
|
112
|
+
556,
|
|
113
|
+
556,
|
|
114
|
+
889,
|
|
115
|
+
667,
|
|
116
|
+
191,
|
|
117
|
+
333,
|
|
118
|
+
333,
|
|
119
|
+
389,
|
|
120
|
+
584,
|
|
121
|
+
278,
|
|
122
|
+
333,
|
|
123
|
+
278,
|
|
124
|
+
278,
|
|
125
|
+
*[556] * 10,
|
|
126
|
+
278,
|
|
127
|
+
278,
|
|
128
|
+
584,
|
|
129
|
+
584,
|
|
130
|
+
584,
|
|
131
|
+
556,
|
|
132
|
+
1015,
|
|
133
|
+
667,
|
|
134
|
+
667,
|
|
135
|
+
722,
|
|
136
|
+
722,
|
|
137
|
+
667,
|
|
138
|
+
611,
|
|
139
|
+
778,
|
|
140
|
+
722,
|
|
141
|
+
278,
|
|
142
|
+
500,
|
|
143
|
+
667,
|
|
144
|
+
556,
|
|
145
|
+
833,
|
|
146
|
+
722,
|
|
147
|
+
778,
|
|
148
|
+
667,
|
|
149
|
+
778,
|
|
150
|
+
722,
|
|
151
|
+
667,
|
|
152
|
+
611,
|
|
153
|
+
722,
|
|
154
|
+
667,
|
|
155
|
+
944,
|
|
156
|
+
667,
|
|
157
|
+
667,
|
|
158
|
+
611,
|
|
159
|
+
278,
|
|
160
|
+
278,
|
|
161
|
+
278,
|
|
162
|
+
469,
|
|
163
|
+
556,
|
|
164
|
+
333,
|
|
165
|
+
556,
|
|
166
|
+
556,
|
|
167
|
+
500,
|
|
168
|
+
556,
|
|
169
|
+
556,
|
|
170
|
+
278,
|
|
171
|
+
556,
|
|
172
|
+
556,
|
|
173
|
+
222,
|
|
174
|
+
222,
|
|
175
|
+
500,
|
|
176
|
+
222,
|
|
177
|
+
833,
|
|
178
|
+
556,
|
|
179
|
+
556,
|
|
180
|
+
556,
|
|
181
|
+
556,
|
|
182
|
+
333,
|
|
183
|
+
500,
|
|
184
|
+
278,
|
|
185
|
+
556,
|
|
186
|
+
500,
|
|
187
|
+
722,
|
|
188
|
+
500,
|
|
189
|
+
500,
|
|
190
|
+
500,
|
|
191
|
+
334,
|
|
192
|
+
260,
|
|
193
|
+
334,
|
|
194
|
+
584,
|
|
195
|
+
],
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
_G_LAMBDA = "M3.6 2.6h2.1l6 10.8h1.7M8.4 7.4 4.2 13.4"
|
|
200
|
+
|
|
201
|
+
_G_ICONS = { # 16 x 16 line drawings, stroked in the badge's colour
|
|
202
|
+
"function": _G_LAMBDA,
|
|
203
|
+
"handler": "M5.2 3.4v9.2l7.4-4.6z",
|
|
204
|
+
"event": "M9.2 1.8 3.6 9h4.2l-1 5.2L12.4 7H8.2z",
|
|
205
|
+
"env": "M2.5 4.5h1.6M7.1 4.5h6.4M2.5 8h6.4M11.9 8h1.6M2.5 11.5h3M8.5 11.5h5M5.6 3v3M10.4 6.5v3M7 10v3",
|
|
206
|
+
"start": "M8 2.2v5.2M4.9 4.4a5 5 0 1 0 6.2 0",
|
|
207
|
+
"http": "M8 2a6 6 0 1 0 0 12A6 6 0 1 0 8 2zM2.2 8h11.6M8 2c-2.1 2.2-2.1 9.8 0 12M8 2c2.1 2.2 2.1 9.8 0 12",
|
|
208
|
+
"cloud": "M4.6 12.6h6.9a2.8 2.8 0 0 0 .5-5.55A4 4 0 0 0 4.3 6.4a3.1 3.1 0 0 0 .3 6.2z",
|
|
209
|
+
"database": "M3 4c0-1.1 2.2-2 5-2s5 .9 5 2-2.2 2-5 2-5-.9-5-2zM3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4"
|
|
210
|
+
"M3 8c0 1.1 2.2 2 5 2s5-.9 5-2",
|
|
211
|
+
"bucket": "M2.4 4.2c0 1 2.5 1.8 5.6 1.8s5.6-.8 5.6-1.8S11.1 2.4 8 2.4 2.4 3.2 2.4 4.2zM2.4 4.2l1.5 8.5"
|
|
212
|
+
"c.2 1 1.9 1.6 4.1 1.6s3.9-.6 4.1-1.6l1.5-8.5",
|
|
213
|
+
"queue": "M2 5.2h3.2v5.6H2zM6.4 5.2h3.2v5.6H6.4zM10.8 5.2H14v5.6h-3.2z",
|
|
214
|
+
"send": "M13.8 2.2 6.9 9.1M13.8 2.2 9.6 13.8 6.9 9.1 2.2 6.4z",
|
|
215
|
+
"key": "M10.4 2.6a3 3 0 1 1-2.2 5.1L3 12.9v1.5h1.9v-1.5h1.5v-1.5l1.8-1.8M10.9 5.1h.01",
|
|
216
|
+
"spark": "M8 1.8l1.5 3.9 3.9 1.5-3.9 1.5L8 12.6 6.5 8.7 2.6 7.2l3.9-1.5zM12.6 11.2l.6 1.5 1.5.6-1.5.6-.6 1.5"
|
|
217
|
+
"-.6-1.5-1.5-.6 1.5-.6z",
|
|
218
|
+
"flow": "M4 2.8a1.6 1.6 0 1 0 0 3.2 1.6 1.6 0 1 0 0-3.2zM12 10a1.6 1.6 0 1 0 0 3.2 1.6 1.6 0 1 0 0-3.2z"
|
|
219
|
+
"M4 6v2.4c0 1.3 1 2.3 2.3 2.3h4.1",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
_G_SERVICE_ICONS = ( # an AWS service's box id, by what it starts with -> its icon
|
|
223
|
+
(
|
|
224
|
+
(
|
|
225
|
+
"aws-dynamodb",
|
|
226
|
+
"aws-rds",
|
|
227
|
+
"aws-redshift",
|
|
228
|
+
"aws-timestream",
|
|
229
|
+
"aws-docdb",
|
|
230
|
+
"aws-neptune",
|
|
231
|
+
"aws-elasticache",
|
|
232
|
+
"aws-keyspaces",
|
|
233
|
+
"aws-athena",
|
|
234
|
+
),
|
|
235
|
+
"database",
|
|
236
|
+
),
|
|
237
|
+
(("aws-s3",), "bucket"),
|
|
238
|
+
(("aws-sqs", "aws-kinesis", "aws-firehose", "aws-mq"), "queue"),
|
|
239
|
+
(("aws-sns", "aws-events", "aws-ses", "aws-pinpoint", "aws-iot"), "send"),
|
|
240
|
+
(("aws-secretsmanager", "aws-ssm", "aws-kms", "aws-sts", "aws-cognito", "aws-iam", "aws-acm"), "key"),
|
|
241
|
+
(
|
|
242
|
+
(
|
|
243
|
+
"aws-bedrock",
|
|
244
|
+
"aws-sagemaker",
|
|
245
|
+
"aws-comprehend",
|
|
246
|
+
"aws-textract",
|
|
247
|
+
"aws-rekognition",
|
|
248
|
+
"aws-translate",
|
|
249
|
+
"aws-polly",
|
|
250
|
+
"aws-transcribe",
|
|
251
|
+
),
|
|
252
|
+
"spark",
|
|
253
|
+
),
|
|
254
|
+
(("aws-stepfunctions", "aws-states", "aws-glue", "aws-batch", "aws-ecs", "aws-codebuild"), "flow"),
|
|
255
|
+
(("aws-lambda",), "function"),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
_G_LOOP = "M12.4 9.3A4.6 4.6 0 1 1 11.3 4.7M12.1 2.3v2.8H9.3"
|
|
259
|
+
|
|
260
|
+
_G_ACCESS = ("reads", "writes", "sends", "runs", "signs")
|
|
261
|
+
|
|
262
|
+
_G_LEGEND = (
|
|
263
|
+
("call", "calls"),
|
|
264
|
+
("reads", "reads"),
|
|
265
|
+
("writes", "writes"),
|
|
266
|
+
("sends", "sends"),
|
|
267
|
+
("runs", "runs"),
|
|
268
|
+
("http", "HTTP request"),
|
|
269
|
+
("signs", "signs links"),
|
|
270
|
+
("env", "configures"),
|
|
271
|
+
("client", "makes a client"),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _g_sans(text: str, size: float, bold: bool = False) -> float:
|
|
276
|
+
"""
|
|
277
|
+
About how wide `text` is in the sans stack, a little generous (Segoe UI, Roboto and Inter run wider).
|
|
278
|
+
"""
|
|
279
|
+
return (
|
|
280
|
+
sum(_G_SANS.get(ch) or (1000 if ch == "…" or _g_wide(ch) else 600) for ch in text)
|
|
281
|
+
/ 1000
|
|
282
|
+
* size
|
|
283
|
+
* (1.1 if bold else 1.05)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _g_mono(text: str, size: float) -> float:
|
|
288
|
+
return (len(text) + sum(map(_g_wide, text))) * 0.61 * size
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _g_wide(ch: str) -> bool:
|
|
292
|
+
"""
|
|
293
|
+
A character drawn twice as wide: Chinese, Japanese, Korean.
|
|
294
|
+
"""
|
|
295
|
+
return unicodedata.east_asian_width(ch) in "WF"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _g_fit(text: str, room: float, measure: Callable[[str], float]) -> str:
|
|
299
|
+
"""
|
|
300
|
+
text, cut with … to fit `room` pixels.
|
|
301
|
+
"""
|
|
302
|
+
if measure(text) <= room:
|
|
303
|
+
return text
|
|
304
|
+
while text and measure(text + "…") > room:
|
|
305
|
+
text = text[:-1]
|
|
306
|
+
return text.rstrip() + "…"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _g_wrap(text: str, room: float, measure: Callable[[str], float], most: int = 2) -> list[str]:
|
|
310
|
+
"""
|
|
311
|
+
Words in lines that fit `room`, at most `most`, the last cut with … when there's more.
|
|
312
|
+
"""
|
|
313
|
+
words, lines, line = text.split(), [], ""
|
|
314
|
+
for i, word in enumerate(words):
|
|
315
|
+
trial = f"{line} {word}".strip()
|
|
316
|
+
if measure(trial) <= room or not line:
|
|
317
|
+
line = trial
|
|
318
|
+
continue
|
|
319
|
+
lines.append(line)
|
|
320
|
+
line = word
|
|
321
|
+
if len(lines) == most:
|
|
322
|
+
line = ""
|
|
323
|
+
lines[-1] = _g_fit(lines[-1] + " " + " ".join(words[i:]), room, measure)
|
|
324
|
+
break
|
|
325
|
+
if line:
|
|
326
|
+
lines.append(line)
|
|
327
|
+
return [_g_fit(x, room, measure) for x in lines[:most]]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _g_logo_name(n: GraphNode) -> str | None:
|
|
331
|
+
"""
|
|
332
|
+
The name of the real logo a box shows, when it has one: the one its plugin names as its icon, else its AWS
|
|
333
|
+
service's ('aws-s3'), else its service's by name (Redis, PostgreSQL, OpenAI). None for a box drawn with a line
|
|
334
|
+
icon.
|
|
335
|
+
"""
|
|
336
|
+
if n.icon and logo(n.icon) is not None:
|
|
337
|
+
return n.icon
|
|
338
|
+
if n.kind == "aws":
|
|
339
|
+
name = AWS_LOGOS.get(n.id.removeprefix("aws-"))
|
|
340
|
+
elif n.kind == "service":
|
|
341
|
+
name = SERVICE_LOGOS.get(n.label.lower())
|
|
342
|
+
else:
|
|
343
|
+
return None
|
|
344
|
+
return name if name and logo(name) is not None else None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _g_logo(n: GraphNode) -> tuple[int, int, str] | None:
|
|
348
|
+
"""
|
|
349
|
+
The full art of the logo a box shows, or None.
|
|
350
|
+
"""
|
|
351
|
+
name = _g_logo_name(n)
|
|
352
|
+
return logo(name) if name else None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _g_logo_svg(found: tuple[int, int, str], x: float, y: float, size: float, css: str) -> str:
|
|
356
|
+
width, height, body = found
|
|
357
|
+
return (
|
|
358
|
+
f'<svg class="logo{" " + css if css else ""}" x="{x:g}" y="{y:g}" width="{size:g}" height="{size:g}" '
|
|
359
|
+
f'viewBox="0 0 {width} {height}" fill="currentColor" aria-hidden="true">{body}</svg>'
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
_G_BADGE = 32 # a box's badge, square: a logo needs the pixels, or its fine lines blur together on a 1x screen
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _g_badge(n: GraphNode, badge_y: float) -> str:
|
|
367
|
+
"""
|
|
368
|
+
A box's badge: an AWS service's icon filling it, another service's logo on a white tile, else a line icon in the
|
|
369
|
+
box's colour.
|
|
370
|
+
"""
|
|
371
|
+
size = _G_BADGE
|
|
372
|
+
found = _g_logo(n)
|
|
373
|
+
if found is None:
|
|
374
|
+
icon = 18 # the 16-unit drawings, a little larger
|
|
375
|
+
return (
|
|
376
|
+
f'<rect class="bdg" x="12" y="{badge_y}" width="{size}" height="{size}" rx="8"/>'
|
|
377
|
+
f'<path class="ico" transform="translate({12 + (size - icon) / 2:g},{badge_y + (size - icon) / 2:g}) '
|
|
378
|
+
f'scale({icon / 16:g})" d="{_G_ICONS[_g_icon(n)]}"/>'
|
|
379
|
+
)
|
|
380
|
+
small = small_logo(_g_logo_name(n) or "")
|
|
381
|
+
if fills(found): # AWS's: both sizes fill the badge, the small mark on a 1x screen and the full art on a 2x one
|
|
382
|
+
clip = f"cn-badge-{n.id}"
|
|
383
|
+
art = (
|
|
384
|
+
_g_logo_svg(small, 12, badge_y, size, "lgs") + _g_logo_svg(found, 12, badge_y, size, "lgf")
|
|
385
|
+
if small is not None
|
|
386
|
+
else _g_logo_svg(found, 12, badge_y, size, "")
|
|
387
|
+
)
|
|
388
|
+
return (
|
|
389
|
+
f'<clipPath id="{clip}"><rect x="12" y="{badge_y}" width="{size}" height="{size}" rx="8"/></clipPath>'
|
|
390
|
+
f'<g clip-path="url(#{clip})">{art}</g>'
|
|
391
|
+
)
|
|
392
|
+
pad = 4
|
|
393
|
+
return f'<rect class="bdg lg" x="12" y="{badge_y}" width="{size}" height="{size}" rx="8"/>' + _g_logo_svg(
|
|
394
|
+
found, 12 + pad, badge_y + pad, size - 2 * pad, ""
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _g_icon(n: GraphNode) -> str:
|
|
399
|
+
if n.icon in _G_ICONS:
|
|
400
|
+
return n.icon
|
|
401
|
+
if n.kind == "aws":
|
|
402
|
+
return next((icon for starts, icon in _G_SERVICE_ICONS if n.id.startswith(starts)), "cloud")
|
|
403
|
+
return n.kind if n.kind in _G_ICONS else "cloud"
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class _GCard:
|
|
407
|
+
"""
|
|
408
|
+
A box of the graph being drawn: its node, its size, and what it shows in what order.
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
def __init__(self, node: GraphNode) -> None:
|
|
412
|
+
self.node = node
|
|
413
|
+
self.width = self.height = self.body = 0.0
|
|
414
|
+
self.head = float(_GRAPH_HEAD)
|
|
415
|
+
self.route = "" # the web route that leads to it ('GET /orders')
|
|
416
|
+
self.rows: list[str] = [] # its listed lines, in the order drawn
|
|
417
|
+
self.prose: list[str] = []
|
|
418
|
+
self.label: list[str] = [] # its name, in one line or two
|
|
419
|
+
self.arrows = 0 # the most arrows on one of its sides: a box with many is tall enough to space them
|
|
420
|
+
|
|
421
|
+
def row_y(self, text: str) -> float | None:
|
|
422
|
+
if text in self.rows:
|
|
423
|
+
return self.body + 6 + _GRAPH_ROW * self.rows.index(text) + _GRAPH_ROW / 2
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _g_measure_label(n: GraphNode, text: str) -> float:
|
|
428
|
+
if n.kind in ("handler", "function"):
|
|
429
|
+
return _g_mono(text, 14 if n.kind == "handler" else 13.5) * 1.03
|
|
430
|
+
return _g_sans(text, 14, bold=True)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _g_need(card: _GCard) -> float:
|
|
434
|
+
"""
|
|
435
|
+
How wide its card would like to be: its name, what's under it and its listed lines whole.
|
|
436
|
+
"""
|
|
437
|
+
n = card.node
|
|
438
|
+
need = 52 + _g_measure_label(n, n.label) + 16 + (26 if n.tone == "warn" else 0)
|
|
439
|
+
if n.sub:
|
|
440
|
+
whole = 52 + _g_sans(n.sub, 12.5) + 14
|
|
441
|
+
if n.kind in ("handler", "function") and "/" in n.sub: # past 190px its folder is shortened instead
|
|
442
|
+
whole = min(whole, max(190.0, 52 + _g_sans(n.sub.rsplit("/", 1)[-1], 12.5) + 14))
|
|
443
|
+
need = max(need, whole)
|
|
444
|
+
if card.route:
|
|
445
|
+
need = max(need, 16 + _g_mono(_g_route(card)[0] + _g_route(card)[2], 12.5) + 36)
|
|
446
|
+
if n.kind in _GRAPH_LISTS:
|
|
447
|
+
need = max(
|
|
448
|
+
[need]
|
|
449
|
+
+ [
|
|
450
|
+
28 + _g_mono(line, 12.5) + 16 + (_g_sans(n.flags[line], 12, True) + 12 if line in n.flags else 0)
|
|
451
|
+
for line in n.lines
|
|
452
|
+
if not line.startswith(("+", "("))
|
|
453
|
+
]
|
|
454
|
+
)
|
|
455
|
+
return need
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _g_route(card: _GCard) -> tuple[str, str, str]:
|
|
459
|
+
"""
|
|
460
|
+
A route's pill: the first route the box answers, its method ('GET', or 'GET, POST') and ' +1' for the rest.
|
|
461
|
+
"""
|
|
462
|
+
first, *rest = card.route.split("; ")
|
|
463
|
+
return first, first.rpartition(" ")[0], f" +{len(rest)}" if rest else ""
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _g_cards(graph: CodeGraph) -> dict[str, _GCard]:
|
|
467
|
+
cards = {n.id: _GCard(n) for n in graph.nodes}
|
|
468
|
+
sides: dict[tuple[str, int], int] = defaultdict(int)
|
|
469
|
+
for e in graph.edges:
|
|
470
|
+
sides[(e.source, 0)] += 1
|
|
471
|
+
sides[(e.target, 1)] += 1
|
|
472
|
+
for (node_id, _), count in sides.items():
|
|
473
|
+
if node_id in cards:
|
|
474
|
+
cards[node_id].arrows = max(cards[node_id].arrows, count)
|
|
475
|
+
for e in graph.edges: # a web app's route goes on the function it leads to
|
|
476
|
+
if e.kind == "call" and e.label and not e.label.startswith("line ") and e.target in cards:
|
|
477
|
+
cards[e.target].route = e.label
|
|
478
|
+
for card in cards.values():
|
|
479
|
+
if card.node.kind in _GRAPH_LISTS:
|
|
480
|
+
card.rows = list(card.node.lines)
|
|
481
|
+
return cards
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _g_code_lines(n: GraphNode, room: float) -> list[str]:
|
|
485
|
+
"""
|
|
486
|
+
A function's name in one line, or in two after the dot before its own name when it's too long for one.
|
|
487
|
+
"""
|
|
488
|
+
name = n.label
|
|
489
|
+
dot = name.rfind(".", 0, name.find("(") if "(" in name else len(name))
|
|
490
|
+
if dot < 1 or _g_measure_label(n, name) <= room:
|
|
491
|
+
return [name]
|
|
492
|
+
return [name[: dot + 1], name[dot + 1 :]]
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _g_size(cards: dict[str, _GCard], widths: dict[int, float], column_of: dict[str, int]) -> None:
|
|
496
|
+
for card in cards.values():
|
|
497
|
+
n = card.node
|
|
498
|
+
card.width = widths[column_of[n.id]]
|
|
499
|
+
room = card.width - 52 - 14 - (24 if n.tone == "warn" else 0)
|
|
500
|
+
if n.kind in ("handler", "function"): # code: broken only after its last dot ('Store.' 'save()'), else clipped
|
|
501
|
+
card.label = [_g_fit(part, room, lambda t: _g_measure_label(n, t)) for part in _g_code_lines(n, room)]
|
|
502
|
+
else:
|
|
503
|
+
card.label = _g_wrap(n.label, room, lambda t: _g_measure_label(n, t))
|
|
504
|
+
card.head = (_GRAPH_HEAD if n.sub else _GRAPH_HEAD_BARE) + _GRAPH_LABEL_LINE * (len(card.label) - 1)
|
|
505
|
+
top = card.head + (_GRAPH_ROUTE if card.route else 0) + (_GRAPH_ROUTE if n.when else 0)
|
|
506
|
+
card.body = top
|
|
507
|
+
if n.kind in _GRAPH_LISTS and card.rows:
|
|
508
|
+
card.height = top + 6 + _GRAPH_ROW * len(card.rows) + 8
|
|
509
|
+
else:
|
|
510
|
+
card.prose = [
|
|
511
|
+
part for line in n.lines for part in _g_wrap(line, card.width - 30, lambda t: _g_sans(t, 12.5))
|
|
512
|
+
][:3]
|
|
513
|
+
card.height = top + (_GRAPH_PROSE * len(card.prose) + 10 if card.prose else 4)
|
|
514
|
+
if n.more:
|
|
515
|
+
card.height += _GRAPH_PROSE + (0 if card.prose else 8)
|
|
516
|
+
card.height = max(card.height, 24 + 12 * card.arrows)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _g_lanes(graph: CodeGraph, cards: dict[str, _GCard]) -> dict[str, int]:
|
|
520
|
+
"""
|
|
521
|
+
Each box's column in the drawing, left to right from 0: its graph column, except that a column of functions (or
|
|
522
|
+
of what the code reaches) taller than _GRAPH_TALL is dealt into lanes side by side, at most _GRAPH_LANES, so a wide
|
|
523
|
+
codebase is drawn wide rather than as one long strip. The arrows into a far lane pass between the near lane's boxes.
|
|
524
|
+
"""
|
|
525
|
+
_g_size(cards, {0: float(_GRAPH_MAX_W)}, {n.id: 0 for n in graph.nodes}) # about how tall each box will be
|
|
526
|
+
by_column: dict[int, list[GraphNode]] = defaultdict(list)
|
|
527
|
+
for n in graph.nodes:
|
|
528
|
+
by_column[n.column].append(n)
|
|
529
|
+
column_of, k = {}, 0
|
|
530
|
+
for column in sorted(by_column):
|
|
531
|
+
nodes = by_column[column]
|
|
532
|
+
tall = sum(cards[n.id].height for n in nodes) + _GRAPH_ROW_GAP * (len(nodes) - 1)
|
|
533
|
+
lanes = 1
|
|
534
|
+
if all(n.kind in ("function", "aws", "service", "http") for n in nodes):
|
|
535
|
+
lanes = max(1, min(_GRAPH_LANES, math.ceil(tall / _GRAPH_TALL), len(nodes) // 2))
|
|
536
|
+
for j, n in enumerate(nodes):
|
|
537
|
+
column_of[n.id] = k + j % lanes
|
|
538
|
+
k += lanes
|
|
539
|
+
return column_of
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _graph_layout(
|
|
543
|
+
graph: CodeGraph, cards: dict[str, _GCard]
|
|
544
|
+
) -> tuple[
|
|
545
|
+
dict[str, tuple[float, float, float, float]],
|
|
546
|
+
dict[int, list[float]],
|
|
547
|
+
float,
|
|
548
|
+
float,
|
|
549
|
+
dict[int, float],
|
|
550
|
+
dict[int, float],
|
|
551
|
+
dict[str, int],
|
|
552
|
+
]:
|
|
553
|
+
"""
|
|
554
|
+
Where each box goes: (x, y, width, height) by its id; for each arrow that skips columns, the heights where it
|
|
555
|
+
crosses the columns between (a waypoint in each, so it runs between their boxes, not behind them); the drawing's
|
|
556
|
+
width and height; and each column's x and width, and each box's column. A column is as wide as its boxes want,
|
|
557
|
+
within limits, and a gap as wide as the arrows through it need. Arrows that skip columns from one box (the
|
|
558
|
+
environment's, start-up's), or into one, share their waypoints, so they run as one line and part near the end,
|
|
559
|
+
instead of each finding its own way. A column too tall to take in is split into lanes side by side. Each column is put in the order of its neighbours' heights, a few passes
|
|
560
|
+
left to right and back (the barycenter method), keeping the order where the fewest arrows cross; then each box is
|
|
561
|
+
moved as near its neighbours as the boxes around it let it be.
|
|
562
|
+
"""
|
|
563
|
+
column_of = _g_lanes(graph, cards)
|
|
564
|
+
order = sorted(set(column_of.values()))
|
|
565
|
+
widths = {k: 0.0 for k in range(len(order))}
|
|
566
|
+
for card in cards.values():
|
|
567
|
+
k = column_of[card.node.id]
|
|
568
|
+
widths[k] = max(widths[k], _g_need(card))
|
|
569
|
+
most = {k: float(_GRAPH_MAX_W) for k in widths}
|
|
570
|
+
for n in graph.nodes:
|
|
571
|
+
if n.kind in ("event", "env", "handler"):
|
|
572
|
+
k = column_of[n.id]
|
|
573
|
+
most[k] = max(most[k], _GRAPH_MAX_HANDLER if n.kind == "handler" else _GRAPH_MAX_IN)
|
|
574
|
+
if n.kind in _GRAPH_LISTS and n.flags: # the line a warning is about, whole beside its flag
|
|
575
|
+
k = column_of[n.id]
|
|
576
|
+
most[k] = max(most[k], min(_g_need(cards[n.id]), _GRAPH_MAX_FLAGGED))
|
|
577
|
+
widths = {k: min(max(w, _GRAPH_MIN_W), most[k]) for k, w in widths.items()}
|
|
578
|
+
_g_size(cards, widths, column_of)
|
|
579
|
+
height = {n.id: cards[n.id].height for n in graph.nodes}
|
|
580
|
+
columns: dict[int, list[str]] = defaultdict(list)
|
|
581
|
+
for node in graph.nodes:
|
|
582
|
+
columns[column_of[node.id]].append(node.id)
|
|
583
|
+
skips = [(i, e) for i, e in enumerate(graph.edges) if abs(column_of[e.target] - column_of[e.source]) > 1]
|
|
584
|
+
fan_out: dict[str, int] = defaultdict(int)
|
|
585
|
+
fan_in: dict[str, int] = defaultdict(int)
|
|
586
|
+
for _, e in skips:
|
|
587
|
+
fan_out[e.source] += 1
|
|
588
|
+
fan_in[e.target] += 1
|
|
589
|
+
near: dict[str, list[str]] = defaultdict(list)
|
|
590
|
+
hops: dict[int, list[str]] = {}
|
|
591
|
+
for i, e in enumerate(graph.edges):
|
|
592
|
+
a, b = column_of[e.source], column_of[e.target]
|
|
593
|
+
chain = [e.source]
|
|
594
|
+
if abs(b - a) > 1: # a waypoint in each column it crosses, either way, shared with the arrows it bundles with
|
|
595
|
+
step = 1 if b > a else -1
|
|
596
|
+
if fan_out[e.source] > 1 and fan_out[e.source] >= fan_in[e.target]:
|
|
597
|
+
key = f"~s{step}.{e.source}"
|
|
598
|
+
elif fan_in[e.target] > 1:
|
|
599
|
+
key = f"~t{step}.{e.target}"
|
|
600
|
+
else:
|
|
601
|
+
key = f"~{i}"
|
|
602
|
+
hops[i] = [f"{key}.{k}" for k in range(a + step, b, step)]
|
|
603
|
+
for k, hop in zip(range(a + step, b, step), hops[i]):
|
|
604
|
+
if hop not in column_of:
|
|
605
|
+
columns[k].append(hop)
|
|
606
|
+
column_of[hop] = k
|
|
607
|
+
height[hop] = 4.0
|
|
608
|
+
chain += hops[i]
|
|
609
|
+
chain.append(e.target)
|
|
610
|
+
for x, y in zip(chain, chain[1:]):
|
|
611
|
+
if x != y:
|
|
612
|
+
near[x].append(y)
|
|
613
|
+
near[y].append(x)
|
|
614
|
+
segments = {
|
|
615
|
+
tuple(sorted((x, y), key=lambda item: column_of[item]))
|
|
616
|
+
for x, ys in near.items()
|
|
617
|
+
for y in ys
|
|
618
|
+
if abs(column_of[x] - column_of[y]) == 1
|
|
619
|
+
}
|
|
620
|
+
by_gap: dict[int, list[tuple[str, str]]] = defaultdict(list)
|
|
621
|
+
for x, y in segments:
|
|
622
|
+
by_gap[column_of[x]].append((x, y))
|
|
623
|
+
center: dict[str, float] = {}
|
|
624
|
+
for k in range(len(order)):
|
|
625
|
+
top = 0.0
|
|
626
|
+
for item in columns[k]:
|
|
627
|
+
center[item] = top + height[item] / 2
|
|
628
|
+
top += height[item] + _GRAPH_ROW_GAP
|
|
629
|
+
|
|
630
|
+
def gap(x: str, y: str) -> float:
|
|
631
|
+
return _GRAPH_ROW_GAP if not (x.startswith("~") or y.startswith("~")) else _GRAPH_HOP_GAP
|
|
632
|
+
|
|
633
|
+
def settle(items: list[str], wanted: list[float]) -> None:
|
|
634
|
+
"""
|
|
635
|
+
Each box at the height it wants, pushed down so none overlaps, then the column moved back up by how far
|
|
636
|
+
it was pushed on average.
|
|
637
|
+
"""
|
|
638
|
+
at = list(wanted)
|
|
639
|
+
for j in range(1, len(items)):
|
|
640
|
+
at[j] = max(
|
|
641
|
+
at[j], at[j - 1] + height[items[j - 1]] / 2 + gap(items[j - 1], items[j]) + height[items[j]] / 2
|
|
642
|
+
)
|
|
643
|
+
shift = sum(w - a for w, a in zip(wanted, at)) / len(items)
|
|
644
|
+
for item, a in zip(items, at):
|
|
645
|
+
center[item] = a + shift
|
|
646
|
+
|
|
647
|
+
def crossings() -> int:
|
|
648
|
+
"""
|
|
649
|
+
How many pairs of arrows cross in the gaps, with the columns in their order now.
|
|
650
|
+
"""
|
|
651
|
+
rank = {item: j for items in columns.values() for j, item in enumerate(items)}
|
|
652
|
+
count = 0
|
|
653
|
+
for pairs in by_gap.values():
|
|
654
|
+
ends = sorted((rank[x], rank[y]) for x, y in pairs)
|
|
655
|
+
for j, (a1, b1) in enumerate(ends):
|
|
656
|
+
count += sum(1 for a2, b2 in ends[j + 1 :] if a2 > a1 and b2 < b1)
|
|
657
|
+
return count
|
|
658
|
+
|
|
659
|
+
counted = len(segments) <= _GRAPH_COUNTED
|
|
660
|
+
best = (crossings() if counted else 0, {k: list(v) for k, v in columns.items()}, dict(center))
|
|
661
|
+
for sweep in range(_GRAPH_SWEEPS if counted else _GRAPH_SWEEPS_BIG):
|
|
662
|
+
for k in range(len(order)) if sweep % 2 == 0 else range(len(order) - 1, -1, -1):
|
|
663
|
+
items = columns[k]
|
|
664
|
+
|
|
665
|
+
def pull(item: str) -> float:
|
|
666
|
+
ys = [center[m] for m in near[item] if column_of[m] != k]
|
|
667
|
+
return sum(ys) / len(ys) if ys else center[item]
|
|
668
|
+
|
|
669
|
+
items.sort(key=pull)
|
|
670
|
+
settle(items, [pull(item) for item in items])
|
|
671
|
+
if counted and (crossed := crossings()) < best[0]:
|
|
672
|
+
best = (crossed, {k: list(v) for k, v in columns.items()}, dict(center))
|
|
673
|
+
if counted:
|
|
674
|
+
columns.clear()
|
|
675
|
+
columns.update(best[1])
|
|
676
|
+
center = best[2]
|
|
677
|
+
top = min(center[item] - height[item] / 2 for item in center)
|
|
678
|
+
last = len(order) - 1 # a call within the last column curls round into the margin; the others, into a gap
|
|
679
|
+
through: dict[int, int] = defaultdict(
|
|
680
|
+
int
|
|
681
|
+
) # arrows that cross each gap (the gap after column k), counted once per line drawn
|
|
682
|
+
for x, y in segments:
|
|
683
|
+
through[column_of[x]] += 1
|
|
684
|
+
for e in graph.edges:
|
|
685
|
+
if column_of[e.source] == column_of[e.target] and column_of[e.source] < last:
|
|
686
|
+
through[column_of[e.source]] += 1
|
|
687
|
+
xs, x = {}, float(_GRAPH_PAD_X)
|
|
688
|
+
for k in range(len(order)):
|
|
689
|
+
xs[k] = x
|
|
690
|
+
x += widths[k] + (min(_GRAPH_GAP + _GRAPH_GAP_EACH * through[k], _GRAPH_GAP_MAX) if k < last else 0)
|
|
691
|
+
boxes = {
|
|
692
|
+
n.id: (
|
|
693
|
+
xs[column_of[n.id]],
|
|
694
|
+
center[n.id] - height[n.id] / 2 - top + _GRAPH_BAND,
|
|
695
|
+
widths[column_of[n.id]],
|
|
696
|
+
height[n.id],
|
|
697
|
+
)
|
|
698
|
+
for n in graph.nodes
|
|
699
|
+
}
|
|
700
|
+
routes = {i: [center[hop] - top + _GRAPH_BAND for hop in chain] for i, chain in hops.items()}
|
|
701
|
+
loops = 64 if any(column_of[e.source] == column_of[e.target] == last for e in graph.edges) else 0
|
|
702
|
+
width = x + _GRAPH_PAD_X + loops
|
|
703
|
+
tall = (
|
|
704
|
+
max(max(y + h for _, y, _, h in boxes.values()), max((y for ys in routes.values() for y in ys), default=0))
|
|
705
|
+
+ _GRAPH_PAD_B
|
|
706
|
+
)
|
|
707
|
+
return boxes, routes, width, tall, xs, widths, column_of
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _g_names(e: GraphEdge) -> list[str]:
|
|
711
|
+
return [part for part in e.label.split(", ") if part]
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _g_rows(card: _GCard, e: GraphEdge) -> list[str]:
|
|
715
|
+
"""
|
|
716
|
+
The rows of its box an arrow leaves from or lands on: the variables it carries, the operations it calls (the
|
|
717
|
+
'+3 more' row for one that isn't listed); none for the event as a whole or a client.
|
|
718
|
+
"""
|
|
719
|
+
if card.node.kind not in _GRAPH_LISTS or e.kind in ("event", "client"):
|
|
720
|
+
return []
|
|
721
|
+
if e.label in card.rows:
|
|
722
|
+
return [e.label]
|
|
723
|
+
hit = [r for r in card.rows if f", {r}, " in f", {e.label}, "]
|
|
724
|
+
if not hit and any(r.startswith("+") for r in card.rows):
|
|
725
|
+
hit = [r for r in card.rows if r.startswith("+")]
|
|
726
|
+
return [r for r in hit if card.row_y(r) is not None]
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _g_anchors(
|
|
730
|
+
graph: CodeGraph, cards: dict[str, _GCard], boxes: dict[str, tuple[float, float, float, float]]
|
|
731
|
+
) -> dict[int, tuple[list[float], list[float]]]:
|
|
732
|
+
"""
|
|
733
|
+
Where each arrow leaves and arrives, as heights on the page: from the rows of the variables it carries, to the
|
|
734
|
+
rows of the operations or requests it makes; otherwise spread down the box's side in the order of what's at the
|
|
735
|
+
other end, outgoing arrows and incoming ones on separate parts of a side they share. Rows are first put in the
|
|
736
|
+
order of what they're joined to, so arrows cross less.
|
|
737
|
+
"""
|
|
738
|
+
|
|
739
|
+
def middle(node_id: str) -> float:
|
|
740
|
+
return boxes[node_id][1] + boxes[node_id][3] / 2
|
|
741
|
+
|
|
742
|
+
rows_for = _g_rows
|
|
743
|
+
joined: dict[str, dict[str, list[float]]] = defaultdict(lambda: defaultdict(list))
|
|
744
|
+
for e in graph.edges:
|
|
745
|
+
for here, there in ((e.source, e.target), (e.target, e.source)):
|
|
746
|
+
for row in rows_for(cards[here], e):
|
|
747
|
+
joined[here][row].append(middle(there))
|
|
748
|
+
for node_id, by_row in joined.items():
|
|
749
|
+
rank = {row: sum(ys) / len(ys) for row, ys in by_row.items()}
|
|
750
|
+
cards[node_id].rows.sort(key=lambda r: (r.startswith("+"), rank.get(r, 1e9)))
|
|
751
|
+
|
|
752
|
+
ends: dict[int, list[list[float]]] = {}
|
|
753
|
+
loose: dict[tuple[str, str], list[tuple[int, int]]] = defaultdict(list) # (node, side of the box) -> (edge, end)
|
|
754
|
+
for i, e in enumerate(graph.edges):
|
|
755
|
+
pair: list[list[float]] = []
|
|
756
|
+
for end, node_id in ((0, e.source), (1, e.target)):
|
|
757
|
+
card = cards[node_id]
|
|
758
|
+
ys = [boxes[node_id][1] + y for r in rows_for(card, e) if (y := card.row_y(r)) is not None]
|
|
759
|
+
if not ys: # which side of the box the arrow touches there: right, unless it arrives from the left
|
|
760
|
+
forward = boxes[e.target][0] > boxes[e.source][0]
|
|
761
|
+
side = (
|
|
762
|
+
"left"
|
|
763
|
+
if (end == 1 and forward) or (end == 0 and boxes[e.target][0] < boxes[e.source][0])
|
|
764
|
+
else "right"
|
|
765
|
+
)
|
|
766
|
+
loose[(node_id, side)].append((i, end))
|
|
767
|
+
pair.append(ys)
|
|
768
|
+
ends[i] = pair
|
|
769
|
+
|
|
770
|
+
def other(i: int, end: int) -> float:
|
|
771
|
+
far = ends[i][1 - end]
|
|
772
|
+
e = graph.edges[i]
|
|
773
|
+
return sum(far) / len(far) if far else middle(e.target if end == 0 else e.source)
|
|
774
|
+
|
|
775
|
+
for (node_id, _), items in loose.items():
|
|
776
|
+
card, (_, y, _, h) = cards[node_id], boxes[node_id]
|
|
777
|
+
items.sort(key=lambda item: (item[1], other(*item))) # arrivals above departures on a shared side
|
|
778
|
+
if card.node.kind in _GRAPH_LISTS: # at the name: the event as a whole, a client made for the service
|
|
779
|
+
hub, room = y + 27, 22.0
|
|
780
|
+
else:
|
|
781
|
+
hub, room = y + h / 2, h - 22
|
|
782
|
+
step = min(12.0, room / max(len(items), 1))
|
|
783
|
+
for k, (i, end) in enumerate(items):
|
|
784
|
+
ends[i][end] = [hub + (k - (len(items) - 1) / 2) * step]
|
|
785
|
+
shared: dict[tuple[str, int, float], list[int]] = defaultdict(list) # arrows that share a row's port
|
|
786
|
+
for i, e in enumerate(graph.edges):
|
|
787
|
+
for end, node_id in ((0, e.source), (1, e.target)):
|
|
788
|
+
for y in ends[i][end]:
|
|
789
|
+
shared[(node_id, end, round(y, 1))].append(i)
|
|
790
|
+
for (node_id, end, y), edges in shared.items():
|
|
791
|
+
edges = [i for i in edges if graph.edges[i].kind != "env"] # the environment's leave as one line, then part
|
|
792
|
+
if len(edges) > 1 and end == 0: # leaving one row: side by side, within the row (arriving: one head)
|
|
793
|
+
edges.sort(key=lambda i: other(i, end))
|
|
794
|
+
step = min(5.0, (_GRAPH_ROW - 6) / (len(edges) - 1))
|
|
795
|
+
for k, i in enumerate(edges):
|
|
796
|
+
ends[i][end] = [v + (k - (len(edges) - 1) / 2) * step if round(v, 1) == y else v for v in ends[i][end]]
|
|
797
|
+
return {i: (pair[0], pair[1]) for i, pair in ends.items()}
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _g_cubic(
|
|
801
|
+
p0: tuple[float, float], p1: tuple[float, float], p2: tuple[float, float], p3: tuple[float, float], t: float
|
|
802
|
+
) -> tuple[float, float]:
|
|
803
|
+
u = 1 - t
|
|
804
|
+
x, y = (u**3 * a + 3 * u * u * t * b + 3 * u * t * t * c + t**3 * d for a, b, c, d in zip(p0, p1, p2, p3))
|
|
805
|
+
return x, y
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _g_spots(curve: tuple[tuple[float, float], ...]) -> list[tuple[float, float]]:
|
|
809
|
+
return [_g_cubic(*curve, t) for t in (0.5, 0.3, 0.7, 0.18, 0.82)] # type: ignore[arg-type]
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _g_strand(points: list[tuple[float, float]]) -> tuple[str, list[tuple[float, float]]]:
|
|
813
|
+
"""
|
|
814
|
+
One line through `points`: (start, then a column's left and right for each column it skips, then the end).
|
|
815
|
+
Curves in the gaps, straight across each column it skips; returns the path and where its marks can go, the best
|
|
816
|
+
first: along its middle straight run, in the clear between a column's boxes, or else along its curve.
|
|
817
|
+
"""
|
|
818
|
+
(x1, y1) = points[0]
|
|
819
|
+
parts: list[str] = [f"M{x1:.1f},{y1:.1f}"]
|
|
820
|
+
runs: list[tuple[float, float, float]] = []
|
|
821
|
+
curve: tuple[tuple[float, float], ...] = ()
|
|
822
|
+
for j in range(1, len(points)):
|
|
823
|
+
(ax, ay), (bx, by) = points[j - 1], points[j]
|
|
824
|
+
if j % 2 == 0 and j < len(points) - 1:
|
|
825
|
+
parts.append(f"L{bx:.1f},{by:.1f}")
|
|
826
|
+
runs.append((ax, bx, by))
|
|
827
|
+
continue
|
|
828
|
+
reach = max(18.0, abs(bx - ax) * 0.5) * (1 if bx >= ax else -1)
|
|
829
|
+
parts.append(f"C{ax + reach:.1f},{ay:.1f} {bx - reach:.1f},{by:.1f} {bx:.1f},{by:.1f}")
|
|
830
|
+
curve = curve or ((ax, ay), (ax + reach, ay), (bx - reach, by), (bx, by))
|
|
831
|
+
if not runs:
|
|
832
|
+
return " ".join(parts), _g_spots(curve)
|
|
833
|
+
ax, bx, y = runs[(len(runs) - 1) // 2]
|
|
834
|
+
middle, d = (ax + bx) / 2, (1 if bx >= ax else -1)
|
|
835
|
+
return " ".join(parts), [(middle + k * 19 * d, y) for k in (0, 1, -1, 2, -2) if abs(k * 19) < abs(bx - ax) / 2 - 9]
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _g_head(x: float, y: float, d: int) -> str:
|
|
839
|
+
"""
|
|
840
|
+
A slim arrowhead with a notched back, its tip at (x, y), pointing right (d=1) or left (d=-1).
|
|
841
|
+
"""
|
|
842
|
+
return f"M{x:.1f},{y:.1f} L{x - 9 * d:.1f},{y - 4.4:.1f} Q{x - 6.2 * d:.1f},{y:.1f} {x - 9 * d:.1f},{y + 4.4:.1f} Z"
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _g_edge_tip(e: GraphEdge, graph: CodeGraph) -> str:
|
|
846
|
+
"""
|
|
847
|
+
What an arrow says on hover, in a sentence.
|
|
848
|
+
"""
|
|
849
|
+
source, target = graph.node(e.source), graph.node(e.target)
|
|
850
|
+
src = source.label if source else e.source
|
|
851
|
+
dst = target.label if target else e.target
|
|
852
|
+
if e.kind == "event":
|
|
853
|
+
said = f"{dst} is called with its input"
|
|
854
|
+
elif e.kind == "env":
|
|
855
|
+
said = f"{dst} reads {e.label} from its environment"
|
|
856
|
+
elif e.kind == "call":
|
|
857
|
+
said = f"{src} calls {dst}" + (f" ({e.label})" if e.label else "") + (f", only when {e.when}" if e.when else "")
|
|
858
|
+
elif e.kind == "client":
|
|
859
|
+
said = f"{src} makes the {dst} client"
|
|
860
|
+
elif e.kind == "http":
|
|
861
|
+
said = f"{src} sends {e.label}"
|
|
862
|
+
else:
|
|
863
|
+
said = f"{src} {_EDGE_WORDS.get(e.kind, e.kind)} {dst}: {e.label}"
|
|
864
|
+
said += "; in a loop, once per item" if e.loop else ""
|
|
865
|
+
said += "; known only by the methods' names (their client couldn't be followed)" if e.guessed else ""
|
|
866
|
+
said += "; in functions further down that the graph leaves out" if e.indirect else ""
|
|
867
|
+
return said + ("; a warning is about it" if e.tone == "warn" else "")
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _edge_svg(
|
|
871
|
+
e: GraphEdge,
|
|
872
|
+
graph: CodeGraph,
|
|
873
|
+
boxes: dict[str, tuple[float, float, float, float]],
|
|
874
|
+
xs: dict[int, float],
|
|
875
|
+
widths: dict[int, float],
|
|
876
|
+
column_of: dict[str, int],
|
|
877
|
+
ys_out: list[float],
|
|
878
|
+
ys_in: list[float],
|
|
879
|
+
hops: list[float] | None,
|
|
880
|
+
mark_loop: bool = True,
|
|
881
|
+
hot: set[tuple[int, int]] | None = None,
|
|
882
|
+
taken: list[tuple[float, float]] | None = None,
|
|
883
|
+
lane: int = 0,
|
|
884
|
+
) -> tuple[str, str]:
|
|
885
|
+
"""
|
|
886
|
+
An arrow: its lines (one from each row it leaves to each row it reaches), its head, its loop mark; and, drawn
|
|
887
|
+
over the boxes, the dots where it leaves them. A warned arrow's glow and ! go on the lines in `hot` (by the index
|
|
888
|
+
of the row each leaves and reaches), or on all of them. Its marks go on its line, clear of the marks in `taken`
|
|
889
|
+
(other arrows'), which they're added to; `lane` puts a call within a column outside the ones drawn before it.
|
|
890
|
+
"""
|
|
891
|
+
sx, _, sw, _ = boxes[e.source]
|
|
892
|
+
tx, _, tw, _ = boxes[e.target]
|
|
893
|
+
lines: list[str] = []
|
|
894
|
+
spots: list[tuple[float, float]] = [] # where its marks can go, along its first line (or the first one warned)
|
|
895
|
+
start, end = column_of[e.source], column_of[e.target]
|
|
896
|
+
if end > start: # left to right: from the source's right side to the target's left side
|
|
897
|
+
x1, tip, d = sx + sw, tx - 0.6, 1
|
|
898
|
+
between = [(xs[start + 1 + k], xs[start + 1 + k] + widths[start + 1 + k], y) for k, y in enumerate(hops or [])]
|
|
899
|
+
elif end < start: # back to an earlier column: from the source's left side to the target's right side
|
|
900
|
+
x1, tip, d = sx, tx + tw + 0.6, -1
|
|
901
|
+
between = [(xs[start - 1 - k] + widths[start - 1 - k], xs[start - 1 - k], y) for k, y in enumerate(hops or [])]
|
|
902
|
+
else: # the same column: out of the right side, round, and back into the target's right side
|
|
903
|
+
x1, tip, d = sx + sw, tx + tw + 0.6, -1
|
|
904
|
+
between = []
|
|
905
|
+
warned: list[str] = []
|
|
906
|
+
for ia, a in enumerate(ys_out):
|
|
907
|
+
for ib, b in enumerate(ys_in):
|
|
908
|
+
if end == start:
|
|
909
|
+
bulge = 34 + 16 * lane + 8 * len(lines)
|
|
910
|
+
curve = ((x1, a), (x1 + bulge, a), (tip + 6.6 + bulge, b), (tip + 6.6, b))
|
|
911
|
+
path = "M{:.1f},{:.1f} C{:.1f},{:.1f} {:.1f},{:.1f} {:.1f},{:.1f}".format(
|
|
912
|
+
*(v for p in curve for v in p)
|
|
913
|
+
)
|
|
914
|
+
at = _g_spots(curve)
|
|
915
|
+
else:
|
|
916
|
+
points = (
|
|
917
|
+
[(x1, a)] + [(p, y) for left, right, y in between for p in (left, right)] + [(tip - 6.6 * d, b)]
|
|
918
|
+
)
|
|
919
|
+
path, at = _g_strand(points)
|
|
920
|
+
lines.append(path)
|
|
921
|
+
spots = spots or at
|
|
922
|
+
if e.tone == "warn" and (hot is None or (ia, ib) in hot):
|
|
923
|
+
if not warned: # the marks go on the first line the warning is about
|
|
924
|
+
spots = at
|
|
925
|
+
warned.append(path)
|
|
926
|
+
heads = [_g_head(tip, b, d) for b in dict.fromkeys(ys_in)]
|
|
927
|
+
d_attr = " ".join(lines)
|
|
928
|
+
classes = (
|
|
929
|
+
f"ce ek-{_esc(e.kind)} s-{_esc(e.source)} t-{_esc(e.target)}"
|
|
930
|
+
+ (" warn" if e.tone == "warn" else "")
|
|
931
|
+
+ (" guess" if e.guessed else "")
|
|
932
|
+
+ (" via" if e.indirect else "")
|
|
933
|
+
)
|
|
934
|
+
taken = [] if taken is None else taken
|
|
935
|
+
|
|
936
|
+
def place() -> tuple[float, float]: # the first spot clear of every mark drawn so far
|
|
937
|
+
at = next((p for p in spots if all(math.dist(p, q) >= 17.5 for q in taken)), spots[0])
|
|
938
|
+
taken.append(at)
|
|
939
|
+
return at
|
|
940
|
+
|
|
941
|
+
mark = ""
|
|
942
|
+
if spots and e.tone == "warn":
|
|
943
|
+
cx, cy = place()
|
|
944
|
+
mark += (
|
|
945
|
+
f'<g class="wm" transform="translate({cx:.1f},{cy:.1f})"><circle r="7.5"/>'
|
|
946
|
+
'<path d="M0,-3.7V.5M0,3.4V3.5"/></g>'
|
|
947
|
+
)
|
|
948
|
+
if spots and e.loop and mark_loop:
|
|
949
|
+
cx, cy = place()
|
|
950
|
+
mark += (
|
|
951
|
+
f'<g class="lpb" transform="translate({cx:.1f},{cy:.1f})"><circle r="8.5"/>'
|
|
952
|
+
f'<path transform="scale(.66) translate(-8,-8)" d="{_G_LOOP}"/></g>'
|
|
953
|
+
)
|
|
954
|
+
halo = (
|
|
955
|
+
f'<path class="halo" d="{" ".join(warned)}"/><path class="cs" d="{" ".join(warned)}"/>' # amber, edged
|
|
956
|
+
if warned
|
|
957
|
+
else ""
|
|
958
|
+
)
|
|
959
|
+
body = (
|
|
960
|
+
f'<g class="{classes}"><title>{_esc(_g_edge_tip(e, graph))}</title><path class="hit" d="{d_attr}"/>{halo}'
|
|
961
|
+
f'<path class="ln" d="{d_attr}"/>' + "".join(f'<path class="ah" d="{h}"/>' for h in heads) + mark + "</g>"
|
|
962
|
+
)
|
|
963
|
+
dots = (
|
|
964
|
+
f'<g class="{classes} pt">'
|
|
965
|
+
+ "".join(f'<circle cx="{x1:.1f}" cy="{y:.1f}" r="2.8"/>' for y in dict.fromkeys(ys_out))
|
|
966
|
+
+ "</g>"
|
|
967
|
+
)
|
|
968
|
+
return body, dots
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _g_sub_svg(n: GraphNode, room: float) -> str:
|
|
972
|
+
if n.kind in ("handler", "function") and "/" in n.sub: # the folder dimmed, shortened from the left
|
|
973
|
+
folder, _, rest = n.sub.rpartition("/")
|
|
974
|
+
folder += "/"
|
|
975
|
+
if _g_sans(rest, 12.5) > room:
|
|
976
|
+
return _esc(_g_fit(rest, room, lambda t: _g_sans(t, 12.5)))
|
|
977
|
+
if _g_sans(folder + rest, 12.5) > room: # whole folders dropped from the left: '…/orders.py line 10'
|
|
978
|
+
parts = folder.rstrip("/").split("/")
|
|
979
|
+
while parts and _g_sans("…/" + "/".join(parts) + "/" + rest, 12.5) > room:
|
|
980
|
+
parts.pop(0)
|
|
981
|
+
folder = "…/" + ("/".join(parts) + "/" if parts else "")
|
|
982
|
+
if not parts and _g_sans(folder + rest, 12.5) > room:
|
|
983
|
+
folder = ""
|
|
984
|
+
return (f'<tspan class="dir">{_esc(folder)}</tspan>' if folder else "") + _esc(rest)
|
|
985
|
+
if (
|
|
986
|
+
n.kind in ("aws", "service") and n.sub
|
|
987
|
+
): # 'reads · writes', each in its arrows' colour, cut with … when they don't fit
|
|
988
|
+
words = n.sub.split(" · ")
|
|
989
|
+
shown = list(words)
|
|
990
|
+
while len(shown) > 1 and _g_sans(" · ".join(shown) + (" · …" if shown != words else ""), 12.5, True) > room:
|
|
991
|
+
shown.pop()
|
|
992
|
+
return '<tspan class="sep"> · </tspan>'.join(
|
|
993
|
+
[f'<tspan class="ac ek-{_esc(w)}">{_esc(w)}</tspan>' for w in shown] + (["…"] if shown != words else [])
|
|
994
|
+
)
|
|
995
|
+
return _esc(_g_fit(n.sub, room, lambda t: _g_sans(t, 12.5)))
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _g_calls(count: int) -> str:
|
|
999
|
+
return f"{count} call" if count == 1 else f"{count} calls"
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _node_svg(
|
|
1003
|
+
card: _GCard, box: tuple[float, float, float, float], neighbours: list[str], access: dict[str, str]
|
|
1004
|
+
) -> str:
|
|
1005
|
+
"""
|
|
1006
|
+
A box: its badge and name, what's under it, a route's pill, its listed lines (an operation's dot in its arrows'
|
|
1007
|
+
colour, a line a warning is about with its flag) or its docstring, and a warning badge.
|
|
1008
|
+
"""
|
|
1009
|
+
n = card.node
|
|
1010
|
+
x, y, w, h = box
|
|
1011
|
+
warn = n.tone == "warn"
|
|
1012
|
+
label_y = 27 if n.sub else 31
|
|
1013
|
+
parts = [
|
|
1014
|
+
f'<text class="lb" x="52" y="{label_y + _GRAPH_LABEL_LINE * k}">{_esc(line)}</text>'
|
|
1015
|
+
for k, line in enumerate(card.label)
|
|
1016
|
+
]
|
|
1017
|
+
if n.sub:
|
|
1018
|
+
parts.append(
|
|
1019
|
+
f'<text class="sb" x="52" y="{45 + _GRAPH_LABEL_LINE * (len(card.label) - 1)}">'
|
|
1020
|
+
f"{_g_sub_svg(n, w - 52 - 12)}</text>"
|
|
1021
|
+
)
|
|
1022
|
+
badge_y = 11 if n.sub else 9
|
|
1023
|
+
parts.append(_g_badge(n, badge_y))
|
|
1024
|
+
top = card.head
|
|
1025
|
+
if card.route:
|
|
1026
|
+
first, method, more = _g_route(card)
|
|
1027
|
+
path = first[len(method) + 1 :]
|
|
1028
|
+
pill_w = min(_g_mono(first + more, 12.5) + 20, w - 28)
|
|
1029
|
+
shown = _g_fit(path, pill_w - 20 - _g_mono(method + " " + more, 12.5), lambda t: _g_mono(t, 12.5))
|
|
1030
|
+
parts.append(
|
|
1031
|
+
f'<g class="rt"><rect x="14" y="{top:.0f}" width="{pill_w:.0f}" height="21" rx="10.5"/>'
|
|
1032
|
+
f'<text x="24" y="{top + 14.8:.1f}"><tspan class="mt">{_esc(method)}</tspan> {_esc(shown)}'
|
|
1033
|
+
f"{_esc(more)}</text></g>"
|
|
1034
|
+
)
|
|
1035
|
+
top += _GRAPH_ROUTE
|
|
1036
|
+
if n.when: # called only under an if: its test, in a pill like a route's
|
|
1037
|
+
test = _g_fit(n.when, w - 28 - 20 - _g_mono("when ", 12.5), lambda t: _g_mono(t, 12.5))
|
|
1038
|
+
pill_w = _g_mono("when " + test, 12.5) + 20
|
|
1039
|
+
parts.append(
|
|
1040
|
+
f'<g class="rt wh"><rect x="14" y="{top:.0f}" width="{pill_w:.0f}" height="21" rx="10.5"/>'
|
|
1041
|
+
f'<text x="24" y="{top + 14.8:.1f}"><tspan class="ww">when </tspan>{_esc(test)}</text></g>'
|
|
1042
|
+
)
|
|
1043
|
+
top += _GRAPH_ROUTE
|
|
1044
|
+
if n.kind in _GRAPH_LISTS and card.rows:
|
|
1045
|
+
parts.append(f'<path class="dv" d="M1,{card.body:.0f}H{w - 1:.0f}"/>')
|
|
1046
|
+
for row in card.rows:
|
|
1047
|
+
ry = card.row_y(row)
|
|
1048
|
+
if ry is None:
|
|
1049
|
+
continue
|
|
1050
|
+
if row.startswith("(") or (row.startswith("+") and row.endswith(" more")):
|
|
1051
|
+
parts.append(f'<text class="rw mo" x="16" y="{ry + 4.3:.1f}">{_esc(row)}</text>')
|
|
1052
|
+
continue
|
|
1053
|
+
tone = (" ac ek-" + _esc(access[row])) if n.kind in ("aws", "service") and row in access else ""
|
|
1054
|
+
flag = n.flags.get(row, "")
|
|
1055
|
+
room = w - 28 - 14 - (_g_sans(flag, 12, True) + 12 if flag else 0)
|
|
1056
|
+
parts.append(
|
|
1057
|
+
f'<circle class="bu{tone}{" flagged" if flag else ""}" cx="17" cy="{ry:.1f}" r="2.6"/>'
|
|
1058
|
+
f'<text class="rw" x="27" y="{ry + 4.3:.1f}">{_esc(_g_fit(row, room, lambda t: _g_mono(t, 12.5)))}'
|
|
1059
|
+
"</text>"
|
|
1060
|
+
)
|
|
1061
|
+
if flag:
|
|
1062
|
+
parts.append(
|
|
1063
|
+
f'<text class="ns" x="{w - 14:.0f}" y="{ry + 4.3:.1f}" text-anchor="end">{_esc(flag)}</text>'
|
|
1064
|
+
)
|
|
1065
|
+
for k, line in enumerate(card.prose):
|
|
1066
|
+
parts.append(f'<text class="pr" x="14" y="{top + 13 + _GRAPH_PROSE * k:.1f}">{_esc(line)}</text>')
|
|
1067
|
+
if n.more:
|
|
1068
|
+
said = f"+{_g_calls(len(n.more))} not drawn"
|
|
1069
|
+
for k in range(len(n.more), 0, -1): # as many of their names as fit whole, the rest as …
|
|
1070
|
+
names = ", ".join(n.more[:k]) + (", …" if k < len(n.more) else "")
|
|
1071
|
+
if _g_sans(f"{said}: {names}", 12.5) <= w - 28:
|
|
1072
|
+
said = f"{said}: {names}"
|
|
1073
|
+
break
|
|
1074
|
+
parts.append(
|
|
1075
|
+
f'<text class="pr mo" x="14" y="{top + 13 + _GRAPH_PROSE * len(card.prose):.1f}">{_esc(said)}</text>'
|
|
1076
|
+
)
|
|
1077
|
+
if warn:
|
|
1078
|
+
parts.append(
|
|
1079
|
+
f'<g class="wb" transform="translate({w - 21:.0f},{badge_y + _G_BADGE / 2:.0f})"><circle r="8.5"/>'
|
|
1080
|
+
'<path d="M0,-4.2V.6M0,3.9V4"/></g>'
|
|
1081
|
+
)
|
|
1082
|
+
classes = " ".join(["cn", f"k-{n.kind}", f"n-{n.id}", *(f"a-{m}" for m in neighbours)]) + (" warn" if warn else "")
|
|
1083
|
+
return (
|
|
1084
|
+
f'<g class="{_esc(classes)}" transform="translate({x:.1f},{y:.1f})"><title>{_esc(n.tip or n.label)}</title>'
|
|
1085
|
+
f'<rect class="bg" width="{w:.0f}" height="{h:.0f}" rx="11"/>'
|
|
1086
|
+
f'<rect class="fg" x=".5" y=".5" width="{w - 1:.0f}" height="{h - 1:.0f}" rx="10.5"/>{"".join(parts)}</g>'
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
_G_PANEL_LINES = 400 # lines of one function a box's panel shows, at most
|
|
1091
|
+
|
|
1092
|
+
_G_PANEL_SPANS = 12 # places a box's panel shows, at most
|
|
1093
|
+
|
|
1094
|
+
_G_PANEL_KINDS = {
|
|
1095
|
+
"event": "What comes in",
|
|
1096
|
+
"env": "Environment",
|
|
1097
|
+
"start": "At import",
|
|
1098
|
+
"handler": "Entry point",
|
|
1099
|
+
"function": "Function",
|
|
1100
|
+
"aws": "AWS service",
|
|
1101
|
+
"service": "Service",
|
|
1102
|
+
"http": "HTTP",
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def _g_panel_id(n: GraphNode) -> str:
|
|
1107
|
+
return f"cn-code-{n.id}"
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _g_filled(line: str) -> bool:
|
|
1111
|
+
"""
|
|
1112
|
+
Whether a coloured line has anything on it.
|
|
1113
|
+
"""
|
|
1114
|
+
return bool(re.sub(r"<[^>]*>", "", line).strip())
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def _g_panel(n: GraphNode, lines_of: Callable[[str], list[str]], editor: str = "") -> str:
|
|
1118
|
+
"""
|
|
1119
|
+
A box's code, in a panel that slides in from the right when the box is clicked (its id is the link's target, so
|
|
1120
|
+
it's CSS's :target that opens it): what the box is, then each place it's about with its lines numbered, the lines
|
|
1121
|
+
worth pointing at lit. '' when none of its code is in the files read.
|
|
1122
|
+
"""
|
|
1123
|
+
shown, lit_any = [], False
|
|
1124
|
+
for span in n.code[:_G_PANEL_SPANS]:
|
|
1125
|
+
lines = lines_of(span.file)
|
|
1126
|
+
start, end = max(1, span.start), min(len(lines), span.end)
|
|
1127
|
+
if n.kind in ("handler", "function"): # its decorators, over its def
|
|
1128
|
+
while start > 1 and lines[start - 2].lstrip().startswith("@"):
|
|
1129
|
+
start -= 1
|
|
1130
|
+
while start < end and start not in span.lit and not _g_filled(lines[start - 1]):
|
|
1131
|
+
start += 1
|
|
1132
|
+
while end > start and end not in span.lit and not _g_filled(lines[end - 1]):
|
|
1133
|
+
end -= 1
|
|
1134
|
+
if start > end:
|
|
1135
|
+
continue
|
|
1136
|
+
more = end - start + 1 - _G_PANEL_LINES
|
|
1137
|
+
end = min(end, start + _G_PANEL_LINES - 1)
|
|
1138
|
+
lit_any |= any(start <= k <= end for k in span.lit)
|
|
1139
|
+
rows = "".join(
|
|
1140
|
+
f'<span class="sl{" lit" if k in span.lit else ""}"><span class="sn">{k}</span>{lines[k - 1]}</span>'
|
|
1141
|
+
for k in range(start, end + 1)
|
|
1142
|
+
)
|
|
1143
|
+
where = f"line {start}" if start == end else f"lines {start}–{end}"
|
|
1144
|
+
href = open_at(editor, span.file, min(span.lit, default=start))
|
|
1145
|
+
link = (
|
|
1146
|
+
f' <a class="ced" href="{_esc(href)}" title="Open it in {_esc(editor_name(editor))}">open ↗</a>'
|
|
1147
|
+
if href
|
|
1148
|
+
else ""
|
|
1149
|
+
)
|
|
1150
|
+
shown.append(
|
|
1151
|
+
f'<div class="csw">{_esc(span.file)} <span>{where}</span>{link}</div>'
|
|
1152
|
+
f'<pre class="csc">{rows}</pre>'
|
|
1153
|
+
+ (f'<div class="csm">+{_plural(more, "more line")}</div>' if more > 0 else "")
|
|
1154
|
+
)
|
|
1155
|
+
if not shown:
|
|
1156
|
+
return ""
|
|
1157
|
+
rest = len(n.code) - _G_PANEL_SPANS
|
|
1158
|
+
if rest > 0:
|
|
1159
|
+
shown.append(f'<div class="csm">+{_plural(rest, "more place")}: the tables below list them all</div>')
|
|
1160
|
+
said = "".join(f'<p class="csd">{_prose(line)}</p>' for line in (n.tip or "").split("\n") if line)
|
|
1161
|
+
if lit_any:
|
|
1162
|
+
said += (
|
|
1163
|
+
'<p class="csd csl">The lit lines are where it calls, reads or sends what its arrows show.</p>'
|
|
1164
|
+
if n.kind in ("handler", "function", "start")
|
|
1165
|
+
else '<p class="csd csl">The lit lines are where the code uses it.</p>'
|
|
1166
|
+
)
|
|
1167
|
+
mono = " mono" if n.kind in ("handler", "function") else ""
|
|
1168
|
+
return (
|
|
1169
|
+
f'<aside class="cside" id="{_esc(_g_panel_id(n))}" aria-label="{_esc("The code of " + n.label)}">'
|
|
1170
|
+
f'<div class="csh"><div class="cst"><span class="csk">{_esc(_G_PANEL_KINDS.get(n.kind, n.kind))}</span>'
|
|
1171
|
+
f'<b class="csn{mono}">{_esc(n.label)}</b>'
|
|
1172
|
+
+ (f'<span class="css">{_esc(n.sub)}</span>' if n.sub else "")
|
|
1173
|
+
+ '</div><a class="csx" href="#cn-closed" aria-label="Close" title="Close">×</a></div>'
|
|
1174
|
+
f"{said}{''.join(shown)}</aside>"
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _g_captions(graph: CodeGraph, xs: dict[int, float], widths: dict[int, float], column_of: dict[str, int]) -> str:
|
|
1179
|
+
"""
|
|
1180
|
+
The words over the columns: COMES IN, HANDLER, CALLS, REACHES, each over the columns it covers.
|
|
1181
|
+
"""
|
|
1182
|
+
kinds: dict[int, set[str]] = defaultdict(set)
|
|
1183
|
+
for n in graph.nodes:
|
|
1184
|
+
kinds[column_of[n.id]].add(n.kind)
|
|
1185
|
+
|
|
1186
|
+
def word(found: set[str]) -> str:
|
|
1187
|
+
return (
|
|
1188
|
+
"Reaches"
|
|
1189
|
+
if found & {"aws", "service", "http"}
|
|
1190
|
+
else "Entry"
|
|
1191
|
+
if "handler" in found
|
|
1192
|
+
else "Comes in"
|
|
1193
|
+
if found & {"event", "env"}
|
|
1194
|
+
else "Calls"
|
|
1195
|
+
if "function" in found
|
|
1196
|
+
else "Start-up"
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
groups: list[list[Any]] = []
|
|
1200
|
+
for k in sorted(kinds):
|
|
1201
|
+
said = word(kinds[k])
|
|
1202
|
+
if groups and groups[-1][0] == said:
|
|
1203
|
+
groups[-1][2] = k
|
|
1204
|
+
else:
|
|
1205
|
+
groups.append([said, k, k])
|
|
1206
|
+
out = []
|
|
1207
|
+
for said, first, last in groups:
|
|
1208
|
+
x, right = xs[first], xs[last] + widths[last]
|
|
1209
|
+
text = said.upper()
|
|
1210
|
+
end = x + _g_sans(text, 12, bold=True) + len(text) * 1.2 + 10
|
|
1211
|
+
out.append(f'<text class="cap" x="{x + 2:.0f}" y="25">{_esc(text)}</text>')
|
|
1212
|
+
if right - end > 12:
|
|
1213
|
+
out.append(f'<path class="capl" d="M{end:.0f},21H{right:.0f}"/>')
|
|
1214
|
+
return "".join(out)
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def _g_swatch(kind: str) -> str:
|
|
1218
|
+
return (
|
|
1219
|
+
f'<svg class="sw ek-{kind}" width="26" height="10" viewBox="0 0 26 10" aria-hidden="true">'
|
|
1220
|
+
f'<path class="ln" d="M1.5,5H17"/><path class="ah" d="{_g_head(24.5, 5, 1)}"/></svg>'
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
@_draws(_Graph, "html")
|
|
1225
|
+
def _graph_html(block: _Graph, report: _Report) -> str:
|
|
1226
|
+
title = f"<h4>{_prose(block.title)}</h4>" if block.title else ""
|
|
1227
|
+
graph = block.graph
|
|
1228
|
+
if not graph.nodes:
|
|
1229
|
+
return title + f'<div class="more">{_esc(block.empty)}</div>'
|
|
1230
|
+
cards = _g_cards(graph)
|
|
1231
|
+
boxes, routes, width, tall, xs, widths, column_of = _graph_layout(graph, cards)
|
|
1232
|
+
ends = _g_anchors(graph, cards, boxes)
|
|
1233
|
+
near: dict[str, list[str]] = defaultdict(list)
|
|
1234
|
+
access: dict[str, dict[str, str]] = defaultdict(dict) # an AWS box's operation -> what it does
|
|
1235
|
+
for e in graph.edges:
|
|
1236
|
+
near[e.source].append(e.target)
|
|
1237
|
+
near[e.target].append(e.source)
|
|
1238
|
+
if e.kind in _G_ACCESS:
|
|
1239
|
+
for name in _g_names(e):
|
|
1240
|
+
access[e.target].setdefault(name, e.kind)
|
|
1241
|
+
root = report.root or _ROOT
|
|
1242
|
+
highlighted: dict[str, list[str]] = {}
|
|
1243
|
+
|
|
1244
|
+
def lines_of(file: str) -> list[str]: # a file's lines in colour, each file coloured once
|
|
1245
|
+
if file not in highlighted:
|
|
1246
|
+
highlighted[file] = _python_lines(block.sources[file]) if file in block.sources else []
|
|
1247
|
+
return highlighted[file]
|
|
1248
|
+
|
|
1249
|
+
panels = {n.id: _g_panel(n, lines_of, block.editor) for n in graph.nodes} if block.sources else {}
|
|
1250
|
+
for n in graph.nodes:
|
|
1251
|
+
if panels.get(n.id): # the box whose code is open, outlined
|
|
1252
|
+
report.rules.add(
|
|
1253
|
+
f".{root} .cgw:has(#{_g_panel_id(n)}:target) .cg .n-{n.id} .fg"
|
|
1254
|
+
"{stroke:light-dark(#2563eb,#60a5fa);stroke-opacity:1;stroke-width:2}"
|
|
1255
|
+
)
|
|
1256
|
+
for n in graph.nodes: # pointing at a box leaves only it, its arrows and the boxes they join in sight
|
|
1257
|
+
report.rules.add(f".{root} .cg:has(.n-{n.id}:hover) .ce:not(.s-{n.id}):not(.t-{n.id}){{opacity:.06}}")
|
|
1258
|
+
report.rules.add(f".{root} .cg:has(.n-{n.id}:hover) .cn:not(.n-{n.id}):not(.a-{n.id}){{opacity:.28}}")
|
|
1259
|
+
report.rules.add(
|
|
1260
|
+
f".{root} .cg:has(.n-{n.id}:hover) :is(.s-{n.id},.t-{n.id}) .ln{{stroke-width:2.4;stroke-opacity:1}}"
|
|
1261
|
+
)
|
|
1262
|
+
marked: set[tuple[str, str]] = set() # one loop mark between two boxes, even for two arrows
|
|
1263
|
+
taken: list[tuple[float, float]] = [] # where marks are, so the next one goes clear of them
|
|
1264
|
+
lanes: dict[frozenset[str], int] = defaultdict(int) # calls within a column between the same two boxes
|
|
1265
|
+
drawn = []
|
|
1266
|
+
for i, e in enumerate(graph.edges):
|
|
1267
|
+
hot = None
|
|
1268
|
+
if e.tone == "warn": # the lines from or to the row the warning is about (TEAM_TOPIC, not set), else all
|
|
1269
|
+
out, into = _g_rows(cards[e.source], e), _g_rows(cards[e.target], e)
|
|
1270
|
+
hot = {
|
|
1271
|
+
(a, b)
|
|
1272
|
+
for a in range(len(ends[i][0]))
|
|
1273
|
+
for b in range(len(ends[i][1]))
|
|
1274
|
+
if (a < len(out) and out[a] in cards[e.source].node.flags)
|
|
1275
|
+
or (b < len(into) and into[b] in cards[e.target].node.flags)
|
|
1276
|
+
} or None
|
|
1277
|
+
lane = 0
|
|
1278
|
+
if column_of[e.source] == column_of[e.target]:
|
|
1279
|
+
lane = lanes[frozenset((e.source, e.target))]
|
|
1280
|
+
lanes[frozenset((e.source, e.target))] += 1
|
|
1281
|
+
drawn.append(
|
|
1282
|
+
_edge_svg(
|
|
1283
|
+
e,
|
|
1284
|
+
graph,
|
|
1285
|
+
boxes,
|
|
1286
|
+
xs,
|
|
1287
|
+
widths,
|
|
1288
|
+
column_of,
|
|
1289
|
+
*ends[i],
|
|
1290
|
+
routes.get(i),
|
|
1291
|
+
(e.source, e.target) not in marked,
|
|
1292
|
+
hot,
|
|
1293
|
+
taken,
|
|
1294
|
+
lane,
|
|
1295
|
+
)
|
|
1296
|
+
)
|
|
1297
|
+
if e.loop:
|
|
1298
|
+
marked.add((e.source, e.target))
|
|
1299
|
+
edges = "".join(body for body, _ in drawn)
|
|
1300
|
+
ports = "".join(dots for _, dots in drawn)
|
|
1301
|
+
nodes = "".join(
|
|
1302
|
+
(
|
|
1303
|
+
f'<a class="cl" href="#{_esc(_g_panel_id(n))}" aria-label="{_esc("Show the code of " + n.label)}">{box}</a>'
|
|
1304
|
+
if panels.get(n.id)
|
|
1305
|
+
else box
|
|
1306
|
+
)
|
|
1307
|
+
for n in graph.nodes
|
|
1308
|
+
for box in [_node_svg(cards[n.id], boxes[n.id], list(dict.fromkeys(near[n.id])), access[n.id])]
|
|
1309
|
+
)
|
|
1310
|
+
kinds = {e.kind for e in graph.edges}
|
|
1311
|
+
pills = [f'<span class="kp">{_g_swatch(k)}{_esc(word)}</span>' for k, word in _G_LEGEND if k in kinds]
|
|
1312
|
+
if any(e.guessed for e in graph.edges):
|
|
1313
|
+
pills.append(f'<span class="kp">{_g_swatch("guess")}guessed from a method\'s name</span>')
|
|
1314
|
+
if any(e.indirect for e in graph.edges):
|
|
1315
|
+
pills.append(f'<span class="kp">{_g_swatch("via")}through functions not drawn</span>')
|
|
1316
|
+
if any(e.tone == "warn" for e in graph.edges) or any(n.tone == "warn" for n in graph.nodes):
|
|
1317
|
+
pills.append(
|
|
1318
|
+
'<span class="kp"><svg class="sw kw" width="16" height="16" viewBox="-8 -8 16 16" '
|
|
1319
|
+
'aria-hidden="true"><circle r="7"/><path d="M0,-3.6V.5M0,3.4V3.5"/></svg>'
|
|
1320
|
+
"a warning is about it</span>"
|
|
1321
|
+
)
|
|
1322
|
+
if any(e.loop for e in graph.edges):
|
|
1323
|
+
pills.append(
|
|
1324
|
+
'<span class="kp"><svg class="sw kl" width="16" height="16" viewBox="-8 -8 16 16" '
|
|
1325
|
+
f'aria-hidden="true"><circle r="7"/><path transform="scale(.6) translate(-8,-8)" d="{_G_LOOP}"/>'
|
|
1326
|
+
"</svg>in a loop: once per item</span>"
|
|
1327
|
+
)
|
|
1328
|
+
hidden = (
|
|
1329
|
+
(
|
|
1330
|
+
f'<span class="kp kh"><b>+{graph.hidden}</b>{"more function" if graph.hidden == 1 else "more functions"} '
|
|
1331
|
+
"not drawn (too deep or too many): the Step by step tab has them all</span>"
|
|
1332
|
+
)
|
|
1333
|
+
if graph.hidden
|
|
1334
|
+
else ""
|
|
1335
|
+
)
|
|
1336
|
+
hint = (
|
|
1337
|
+
'<span class="kt">Point at a box to see only what it\'s joined to'
|
|
1338
|
+
+ ("; click it to read its code" if any(panels.values()) else "")
|
|
1339
|
+
+ "</span>"
|
|
1340
|
+
)
|
|
1341
|
+
made = report.shared("graphs", lambda: [0]) # graphs drawn so far, so each one's buttons have ids of their own
|
|
1342
|
+
group = f"cn-graph{made[0]}"
|
|
1343
|
+
made[0] += 1
|
|
1344
|
+
# its zoom and full screen: inputs before the box they change, so CSS reaches it with ~ (no :has(), no script)
|
|
1345
|
+
switches = f'<input type="checkbox" class="cgs cgfs" id="{group}-full">' + "".join(
|
|
1346
|
+
f'<input type="radio" class="cgs cgz-{key}" name="{group}-zoom" id="{group}-{key}"'
|
|
1347
|
+
f"{' checked' if key == 'auto' else ''}>"
|
|
1348
|
+
for key, _, _ in _GRAPH_ZOOMS
|
|
1349
|
+
)
|
|
1350
|
+
bar = (
|
|
1351
|
+
'<div class="cgt"><span class="cgtz" role="group" aria-label="Zoom">'
|
|
1352
|
+
+ "".join(
|
|
1353
|
+
f'<label class="zl-{key}" for="{group}-{key}" title="{_esc(tip)}">{_esc(word)}</label>'
|
|
1354
|
+
for key, word, tip in _GRAPH_ZOOMS
|
|
1355
|
+
)
|
|
1356
|
+
+ f'</span><label class="cgtf" for="{group}-full"><span class="on">Full screen</span>'
|
|
1357
|
+
'<span class="off">Close full screen</span></label></div>'
|
|
1358
|
+
)
|
|
1359
|
+
# in a box of its own with its panels: the :has() rules that outline a box whose code is open look only in here,
|
|
1360
|
+
# so a radio button elsewhere (the walk's steps) doesn't make the browser restyle the whole page
|
|
1361
|
+
return (
|
|
1362
|
+
f'{title}<div class="cgw">{switches}<div class="cgb">{bar}<div class="cg">'
|
|
1363
|
+
f'<svg viewBox="0 0 {width:.0f} {tall:.0f}" width="{width:.0f}" height="{tall:.0f}" '
|
|
1364
|
+
f'style="--w:{width:.0f}px" role="img" aria-label="{_esc(block.title or "How the code fits together")}">'
|
|
1365
|
+
f"{_g_captions(graph, xs, widths, column_of)}{edges}{nodes}{ports}</svg></div>"
|
|
1366
|
+
f'<div class="cgk">{"".join(pills)}{hidden}{hint}</div></div>{"".join(panels.values())}</div>'
|
|
1367
|
+
)
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
@_draws(_Graph, "text")
|
|
1371
|
+
def _graph_lines(block: _Graph, report: _Report) -> list[str]:
|
|
1372
|
+
out = ["", f"-- {block.title} --"] if block.title else [""]
|
|
1373
|
+
graph = block.graph
|
|
1374
|
+
if not graph.nodes:
|
|
1375
|
+
return out + [block.empty]
|
|
1376
|
+
leaving: dict[str, list[GraphEdge]] = defaultdict(list)
|
|
1377
|
+
for e in graph.edges:
|
|
1378
|
+
leaving[e.source].append(e)
|
|
1379
|
+
for n in sorted(graph.nodes, key=lambda n: n.column):
|
|
1380
|
+
if n.kind in ("aws", "service", "http"):
|
|
1381
|
+
continue
|
|
1382
|
+
lines = [f"{line} ({n.flags[line]})" if line in n.flags else line for line in n.lines]
|
|
1383
|
+
head = n.label + (
|
|
1384
|
+
f": {', '.join(lines)}" if n.kind in ("event", "env") and n.lines else f" {n.sub}" if n.sub else ""
|
|
1385
|
+
)
|
|
1386
|
+
out.append(f" {head}" + (" (!)" if n.tone == "warn" else ""))
|
|
1387
|
+
for e in leaving.get(n.id, []):
|
|
1388
|
+
target = graph.node(e.target)
|
|
1389
|
+
name = target.label if target else e.target
|
|
1390
|
+
said = {
|
|
1391
|
+
"call": f"calls {name}" + (f" ({e.label})" if e.label else "") + (f" when {e.when}" if e.when else ""),
|
|
1392
|
+
"event": f"→ {name}",
|
|
1393
|
+
"env": f"→ {name} reads {e.label}",
|
|
1394
|
+
"client": f"makes the {name} client",
|
|
1395
|
+
"http": f"sends {e.label}",
|
|
1396
|
+
}.get(e.kind) or f"{_EDGE_WORDS.get(e.kind, e.kind)} {name}: {e.label}"
|
|
1397
|
+
said += "".join(
|
|
1398
|
+
[
|
|
1399
|
+
", in a loop" if e.loop else "",
|
|
1400
|
+
", guessed from a method's name" if e.guessed else "",
|
|
1401
|
+
", through functions not drawn" if e.indirect else "",
|
|
1402
|
+
]
|
|
1403
|
+
)
|
|
1404
|
+
out.append(f" {said}" + (" (!)" if e.tone == "warn" else ""))
|
|
1405
|
+
if n.more:
|
|
1406
|
+
out.append(f" +{_g_calls(len(n.more))} not drawn: {', '.join(n.more)}")
|
|
1407
|
+
elif n.kind in ("handler", "function", "start") and not leaving.get(n.id):
|
|
1408
|
+
out.append(" (does nothing this follows: no AWS calls, own functions or HTTP)")
|
|
1409
|
+
if graph.hidden:
|
|
1410
|
+
out.append(f" ({_plural(graph.hidden, 'more function')} not drawn: the steps below have them all)")
|
|
1411
|
+
return out
|