ripple-sql 0.1.2__tar.gz → 0.1.3__tar.gz
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.
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/CHANGELOG.md +10 -1
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/PKG-INFO +1 -1
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/pyproject.toml +1 -1
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/cte_tracing.py +2 -0
- ripple_sql-0.1.3/src/ripple/engine/generators.py +61 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/statement.py +2 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/render.py +45 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/static/answer.css +23 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/static/answer.html +13 -3
- ripple_sql-0.1.3/src/ripple/static/explore.js +190 -0
- ripple_sql-0.1.2/src/ripple/static/explore.js +0 -136
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/.gitignore +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/LICENSE +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/README.md +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/__init__.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/answer.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/answer_page.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/cache.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/ci.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/ci_signature.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/cli.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/doctor.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/__init__.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/budget.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/column_lineage.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/column_ref.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/dependencies.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/dialect.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/dispatch.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/extraction.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/jinja.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/json_sources.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/macro_source.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/pipeline.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/preprocess.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/safe_gen.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/schema_qualification.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/scope.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/select_sources.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/sql_script.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/tech_debt.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/tsql_catalog.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/tsql_scalar_vars.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/tsql_tvf.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/tsql_xml.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/types.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/unused_deps.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/validation.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/graph.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/home.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/loaders/__init__.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/loaders/dbt.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/loaders/dbt_config.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/loaders/identity.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/loaders/sidecar.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/loaders/sqldir.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/loaders/types.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/lookml.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/mcp_server.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/names.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/project.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/py.typed +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/render_shims.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/schemas.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/semantic.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/server.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/sourcefiles.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/star_resolution.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/static/answer_twin.js +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/usage/__init__.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/usage/cli.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/usage/collect.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/usage/discover.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/usage/ingest.py +0 -0
- {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/usage/report.py +0 -0
|
@@ -6,6 +6,14 @@ Notable changes to Ripple. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.3] - 2026-09-09
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- The map of a large project is compact. Past forty models the columns-and-wires layout gave a page-tall column per layer, every link drawn at rest, and long names clipped at the edge (Wikimedia's dbt-jobs: 48 sources, 71 models in one layer). Now the layers stack as wrapped grids, nothing is wired at rest, each layer leads with the models most read and folds the rest behind "more", hovering a model lights its chain (what feeds it in teal, what reads it in ink) and clicking keeps it lit while its columns unfold, and typing hides every model that does not match, with the count of what it hid. Every chip carries the number of models that read it, and the key says so. A small project keeps the columns and wires.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- Two Spark shapes from Wikimedia's dbt-jobs, the first outside repo the published build was tried on. A bare table-generating function in a select list names its own output columns: `INLINE(ARRAY(STRUCT('a' AS platform)))` yields `platform`, `EXPLODE(x)` yields `col`, `POSEXPLODE(x)` yields `pos` and `col`; a lookup table built that way reported no columns at all. dbt's `as_bool`, `as_number`, `as_native` and `as_text` filters are registered on the Jinja environment, so a config that uses one renders instead of failing the whole model.
|
|
16
|
+
|
|
9
17
|
## [0.1.2] - 2026-09-09
|
|
10
18
|
|
|
11
19
|
### Fixed
|
|
@@ -235,7 +243,8 @@ First public release.
|
|
|
235
243
|
### Removed
|
|
236
244
|
- The dark whole-graph canvas that `ripple serve` used to open (`static/index.html`). Every surface now draws one answer.
|
|
237
245
|
|
|
238
|
-
[Unreleased]: https://github.com/bteh/ripple/compare/v0.1.
|
|
246
|
+
[Unreleased]: https://github.com/bteh/ripple/compare/v0.1.3...HEAD
|
|
247
|
+
[0.1.3]: https://github.com/bteh/ripple/releases/tag/v0.1.3
|
|
239
248
|
[0.1.2]: https://github.com/bteh/ripple/releases/tag/v0.1.2
|
|
240
249
|
[0.1.1]: https://github.com/bteh/ripple/releases/tag/v0.1.1
|
|
241
250
|
[0.1.0]: https://github.com/bteh/ripple/releases/tag/v0.1.0
|
|
@@ -39,6 +39,7 @@ from ripple.engine.column_ref import (
|
|
|
39
39
|
under_subquery_where,
|
|
40
40
|
unwrap_select,
|
|
41
41
|
)
|
|
42
|
+
from ripple.engine.generators import name_generator_outputs
|
|
42
43
|
from ripple.engine.preprocess import restore_jinja_dots
|
|
43
44
|
from ripple.engine.safe_gen import safe_sql
|
|
44
45
|
from ripple.engine.scope import register_table
|
|
@@ -120,6 +121,7 @@ def extract_cte_column_lineage(
|
|
|
120
121
|
|
|
121
122
|
cleaned_sql, _ = prepare_sql_for_parse(raw_sql, dialect, clean_jinja_func)
|
|
122
123
|
parsed = sqlglot.parse_one(cleaned_sql, dialect=dialect)
|
|
124
|
+
parsed = name_generator_outputs(parsed)
|
|
123
125
|
if (dialect or "").lower() == "tsql":
|
|
124
126
|
from ripple.engine.tsql_xml import rewrite_xml_method_calls
|
|
125
127
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Bare table-generating functions name their own output columns.
|
|
2
|
+
|
|
3
|
+
Spark's `SELECT INLINE(ARRAY(STRUCT('a' AS platform)))` has no alias and
|
|
4
|
+
no column reference; its output columns are the struct's fields. A bare
|
|
5
|
+
`EXPLODE(x)` is a column called `col`, `POSEXPLODE(x)` is `pos` and `col`.
|
|
6
|
+
sqlglot leaves such items nameless, and the projection walks named them by
|
|
7
|
+
their SQL text or dropped them, so a lookup table built this way reported
|
|
8
|
+
no columns at all (Wikimedia dbt-jobs, platforms_ephemeral).
|
|
9
|
+
|
|
10
|
+
The rewrite gives each such item its default names as aliases before any
|
|
11
|
+
projection walk runs; a field over a column keeps the column as its source.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from sqlglot import exp
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def output_names(item: exp.Expression) -> list[str] | None:
|
|
20
|
+
"""The default output columns of a bare generator, None for anything else."""
|
|
21
|
+
if isinstance(item, exp.Inline):
|
|
22
|
+
array = item.this
|
|
23
|
+
if not (isinstance(array, exp.Array) and array.expressions):
|
|
24
|
+
return None
|
|
25
|
+
first = array.expressions[0]
|
|
26
|
+
if not isinstance(first, exp.Struct):
|
|
27
|
+
return None
|
|
28
|
+
names: list[str] = []
|
|
29
|
+
for field in first.expressions:
|
|
30
|
+
if isinstance(field, exp.PropertyEQ):
|
|
31
|
+
names.append(field.this.name)
|
|
32
|
+
elif isinstance(field, exp.Alias):
|
|
33
|
+
names.append(field.alias)
|
|
34
|
+
elif isinstance(field, exp.Column):
|
|
35
|
+
names.append(field.name)
|
|
36
|
+
else:
|
|
37
|
+
return None
|
|
38
|
+
return names or None
|
|
39
|
+
if isinstance(item, exp.Posexplode):
|
|
40
|
+
return ["pos", "col"]
|
|
41
|
+
if isinstance(item, exp.Explode):
|
|
42
|
+
return ["col"]
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def name_generator_outputs(tree: exp.Expression) -> exp.Expression:
|
|
47
|
+
"""Alias every bare generator in every select list with its default
|
|
48
|
+
output names, in place, and return the tree."""
|
|
49
|
+
for select in list(tree.find_all(exp.Select)):
|
|
50
|
+
items = select.expressions
|
|
51
|
+
if not any(output_names(item) for item in items):
|
|
52
|
+
continue
|
|
53
|
+
renamed: list[exp.Expression] = []
|
|
54
|
+
for item in items:
|
|
55
|
+
names = output_names(item)
|
|
56
|
+
if not names:
|
|
57
|
+
renamed.append(item)
|
|
58
|
+
continue
|
|
59
|
+
renamed.extend(exp.alias_(item.copy(), name) for name in names)
|
|
60
|
+
select.set("expressions", renamed)
|
|
61
|
+
return tree
|
|
@@ -26,6 +26,7 @@ from ripple.engine.cte_tracing import (
|
|
|
26
26
|
star_except_spellings,
|
|
27
27
|
trace_through_ctes,
|
|
28
28
|
)
|
|
29
|
+
from ripple.engine.generators import name_generator_outputs
|
|
29
30
|
from ripple.engine.preprocess import (
|
|
30
31
|
has_unresolved_identifier,
|
|
31
32
|
prepare_sql_for_parse,
|
|
@@ -233,6 +234,7 @@ def extract_column_lineage_fast(
|
|
|
233
234
|
logger.debug("Parsing succeeded with minimal cleaning after full cleaning failed")
|
|
234
235
|
except Exception:
|
|
235
236
|
raise full_clean_error from None
|
|
237
|
+
parsed = name_generator_outputs(parsed)
|
|
236
238
|
|
|
237
239
|
# Validate the parsed result is a valid SELECT-type statement
|
|
238
240
|
if not isinstance(parsed, (exp.Select, exp.Union, exp.Except, exp.Intersect)):
|
|
@@ -275,6 +275,50 @@ class _Modules:
|
|
|
275
275
|
itertools = _itertools
|
|
276
276
|
|
|
277
277
|
|
|
278
|
+
def _as_bool(value):
|
|
279
|
+
"""dbt's as_bool filter: the strings a YAML reader calls true or false."""
|
|
280
|
+
if isinstance(value, bool):
|
|
281
|
+
return value
|
|
282
|
+
text = str(value).strip().lower()
|
|
283
|
+
if text in ("true", "yes", "on", "1"):
|
|
284
|
+
return True
|
|
285
|
+
if text in ("false", "no", "off", "0", "", "none", "null"):
|
|
286
|
+
return False
|
|
287
|
+
return bool(value)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _as_number(value):
|
|
291
|
+
try:
|
|
292
|
+
return int(value)
|
|
293
|
+
except (TypeError, ValueError):
|
|
294
|
+
pass
|
|
295
|
+
try:
|
|
296
|
+
return float(value)
|
|
297
|
+
except (TypeError, ValueError):
|
|
298
|
+
return value
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _as_native(value):
|
|
302
|
+
"""dbt's as_native: a string read back as YAML, anything else untouched."""
|
|
303
|
+
if not isinstance(value, str):
|
|
304
|
+
return value
|
|
305
|
+
try:
|
|
306
|
+
import yaml
|
|
307
|
+
|
|
308
|
+
return yaml.safe_load(value)
|
|
309
|
+
except Exception:
|
|
310
|
+
return value
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# the filters dbt registers on every project's environment
|
|
314
|
+
DBT_FILTERS = {
|
|
315
|
+
"as_bool": _as_bool,
|
|
316
|
+
"as_number": _as_number,
|
|
317
|
+
"as_native": _as_native,
|
|
318
|
+
"as_text": lambda value: "" if value is None else str(value),
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
278
322
|
_SET_BLOCK_RE = re.compile(r"{%-?\s*(call|materialization|test)\b.*?{%-?\s*end\1\s*-?%}", re.S)
|
|
279
323
|
_MACRO_DEF_RE = re.compile(r"{%-?\s*macro\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(")
|
|
280
324
|
_MACRO_DEF_SUB_RE = re.compile(r"({%-?\s*macro\s+)([A-Za-z_][A-Za-z0-9_]*)(\s*\()")
|
|
@@ -299,6 +343,7 @@ def build_environment(
|
|
|
299
343
|
undefined=_Anything,
|
|
300
344
|
extensions=["jinja2.ext.do", "jinja2.ext.loopcontrols"],
|
|
301
345
|
)
|
|
346
|
+
env.filters.update(DBT_FILTERS)
|
|
302
347
|
dialect = registry.dialect if registry else "snowflake"
|
|
303
348
|
env.globals.update(
|
|
304
349
|
{
|
|
@@ -76,6 +76,7 @@ h1 code{font:500 24px/1 var(--mono); letter-spacing:0}
|
|
|
76
76
|
.key i.r{border-top-style:dashed; border-color:var(--review)}
|
|
77
77
|
.key s{display:inline-block; width:11px; height:11px; border-radius:2px; background:var(--ink); border:1px solid var(--ink); text-decoration:none}
|
|
78
78
|
.key s.src{background:var(--well); border:1px dashed var(--line-strong)}
|
|
79
|
+
.key small.n{font:400 11.5px/1 var(--sans); color:var(--faint); border:1px solid var(--line-strong); border-radius:var(--r); padding:2px 5px; background:var(--surface)}
|
|
79
80
|
.hint{margin-left:auto; font-size:12.5px; color:var(--faint)}
|
|
80
81
|
.rows{padding:0 22px 14px; font-size:13.5px; color:var(--muted)}
|
|
81
82
|
.rows b{color:var(--ink); font-weight:600}
|
|
@@ -111,6 +112,28 @@ pre{margin:0; padding:16px 22px 18px; overflow-x:auto; font:400 13px/1.65 var(--
|
|
|
111
112
|
.cols .node:hover{border-color:var(--accent); color:var(--accent-strong)}
|
|
112
113
|
.more-chips{font:500 12.5px var(--sans); color:var(--accent); background:none; border:0; padding:4px 0; cursor:pointer; text-align:left}
|
|
113
114
|
.more-chips:hover{text-decoration:underline}
|
|
115
|
+
/* a lit chain: the model you are on, what feeds it, what it feeds */
|
|
116
|
+
.mdl.sel,.mdl.sel:hover{background:var(--accent); color:#fff; border-color:var(--accent); border-style:solid}
|
|
117
|
+
.mdl.sel small{color:rgba(255,255,255,.7)}
|
|
118
|
+
.mdl.up{border-color:var(--accent); background:#E4F1F4; border-style:solid}
|
|
119
|
+
.mdl.down{border-color:var(--ink); background:var(--well); border-style:solid}
|
|
120
|
+
.key s.up{background:#E4F1F4; border:1px solid var(--accent)}
|
|
121
|
+
.key s.sel{background:var(--accent); border-color:var(--accent)}
|
|
122
|
+
.key s.down{background:var(--well); border:1px solid var(--ink)}
|
|
123
|
+
/* compact: the map of a large project, layers as wrapped grids, no wires */
|
|
124
|
+
.map.compact .board{overflow-x:visible; padding:18px 22px 8px}
|
|
125
|
+
.map.compact .board-inner{flex-direction:column; gap:22px; min-width:0}
|
|
126
|
+
.map.compact .wires{display:none}
|
|
127
|
+
.map.compact .lvl{min-width:0; gap:10px}
|
|
128
|
+
.map.compact .chips{display:flex; flex-wrap:wrap; gap:6px 8px; align-items:flex-start}
|
|
129
|
+
.map.compact .grp{display:contents}
|
|
130
|
+
.map.compact .grp.open{display:flex; flex-basis:100%; flex-direction:column; gap:6px}
|
|
131
|
+
.map.compact .grp.miss{display:none}
|
|
132
|
+
.map.compact .mdl{max-width:100%; overflow-wrap:anywhere; text-align:left; line-height:1.25}
|
|
133
|
+
.map.compact .cols{flex-direction:row; flex-wrap:wrap; gap:6px 8px; margin:0 0 8px 14px; padding:8px 0 8px 10px}
|
|
134
|
+
.map.compact .cols .gname{flex-basis:100%; max-width:60ch}
|
|
135
|
+
.map.compact.focus .mdl:not(.on){opacity:.28}
|
|
136
|
+
.map .hidden-note{color:var(--muted)}
|
|
114
137
|
|
|
115
138
|
.ladder{margin:26px 0 0; display:grid; grid-template-columns:max-content 1fr; gap:12px 20px; align-items:baseline}
|
|
116
139
|
.ladder p{display:contents; margin:0}
|
|
@@ -29,10 +29,13 @@
|
|
|
29
29
|
<section class="map" id="map" hidden aria-label="every model in this project">
|
|
30
30
|
<div class="board"><div class="board-inner" id="mapboard"><svg class="wires" id="mapwires" aria-hidden="true"></svg></div></div>
|
|
31
31
|
<div class="board-foot">
|
|
32
|
-
<span class="key"><i></i> feeds</span>
|
|
32
|
+
<span class="key" id="key-feeds"><i></i> feeds</span>
|
|
33
33
|
<span class="key"><s class="src"></s> a source table</span>
|
|
34
34
|
<span class="key"><s></s> a dashboard number</span>
|
|
35
35
|
<span class="key"><i class="r"></i> needs review</span>
|
|
36
|
+
<span class="key" id="key-chain" hidden><s class="up"></s> feeds it <s class="sel"></s> chosen <s class="down"></s> reads it</span>
|
|
37
|
+
<span class="key"><small class="n">3</small> models that read it</span>
|
|
38
|
+
<span class="hidden-note" id="maphidden" hidden></span>
|
|
36
39
|
<span class="hint" id="maphint">Hover lights the chain. Click a model for its columns, a column to ask.</span>
|
|
37
40
|
</div>
|
|
38
41
|
</section>
|
|
@@ -238,8 +241,15 @@
|
|
|
238
241
|
});
|
|
239
242
|
}
|
|
240
243
|
if (L.map && L.map.models.length){
|
|
241
|
-
|
|
242
|
-
|
|
244
|
+
var hiddenNote = document.getElementById('maphidden');
|
|
245
|
+
mapApi = renderMap(mapEl, L.map, {esc: esc,
|
|
246
|
+
onAsk: function(id){ document.getElementById('q').value = id; ask(id, document.getElementById('dir').value); },
|
|
247
|
+
onFilter: function(n){ hiddenNote.hidden = !n; hiddenNote.textContent = n ? plural(n, 'model') + ' hidden by the filter' : ''; }});
|
|
248
|
+
if (mapApi.compact){
|
|
249
|
+
document.getElementById('key-feeds').hidden = true;
|
|
250
|
+
document.getElementById('key-chain').hidden = false;
|
|
251
|
+
document.getElementById('maphint').textContent = 'Hover lights the chain, click keeps it. Click a column to ask; type to narrow the map.';
|
|
252
|
+
}
|
|
243
253
|
document.getElementById('q').addEventListener('input', function(){ if (!mapEl.hidden) mapApi.filter(this.value.trim()); });
|
|
244
254
|
document.getElementById('tomap').addEventListener('click', function(){ home(); history.replaceState(null, '', location.pathname); });
|
|
245
255
|
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// EXPLORE START
|
|
2
|
+
// The map: every model in its layer, the links between them, and a model's
|
|
3
|
+
// columns when it is clicked. Pure DOM over the map the server embeds;
|
|
4
|
+
// nothing here fetches.
|
|
5
|
+
//
|
|
6
|
+
// Two layouts. A small project (up to COMPACT_AT models) reads left to right
|
|
7
|
+
// as columns with wires, like the answer board. Past that the wires would be
|
|
8
|
+
// a hairball and a layer a page tall, so the map goes compact: layers stack
|
|
9
|
+
// as wrapped grids, nothing is wired at rest, hovering a model lights its
|
|
10
|
+
// chain (upstream in teal, downstream in ink), clicking pins it, and typing
|
|
11
|
+
// hides what does not match.
|
|
12
|
+
var COMPACT_AT = 40, MAP_PER_LAYER = 40, COMPACT_PER_LAYER = 12, MAP_COLUMNS = 60;
|
|
13
|
+
|
|
14
|
+
function renderMap(root, map, opts){
|
|
15
|
+
var inner = root.querySelector('.board-inner'), svg = root.querySelector('.wires');
|
|
16
|
+
var esc = opts.esc, compact = map.models.length > COMPACT_AT;
|
|
17
|
+
var ups = {}, downs = {}, chips = {}, groups = {}, folds = [], lit = null, pinned = null;
|
|
18
|
+
map.links.forEach(function(l){
|
|
19
|
+
(ups[l.dst] = ups[l.dst] || []).push(l.src);
|
|
20
|
+
(downs[l.src] = downs[l.src] || []).push(l.dst);
|
|
21
|
+
});
|
|
22
|
+
root.classList.toggle('compact', compact);
|
|
23
|
+
function el(tag, cls){ var e = document.createElement(tag); if (cls) e.className = cls; return e; }
|
|
24
|
+
function label(m){
|
|
25
|
+
if (m.kind !== 'dashboard') return esc(m.id);
|
|
26
|
+
var kind = m.id.split(':')[0];
|
|
27
|
+
return esc(m.id.slice(kind.length + 1)) + ' <small>' + esc(kind) + '</small>';
|
|
28
|
+
}
|
|
29
|
+
function walk(dir, id){
|
|
30
|
+
var seen = {}, queue = [id];
|
|
31
|
+
while (queue.length){
|
|
32
|
+
var cur = queue.shift();
|
|
33
|
+
(dir[cur] || []).forEach(function(n){ if (!seen[n] && n !== id){ seen[n] = true; queue.push(n); } });
|
|
34
|
+
}
|
|
35
|
+
return seen;
|
|
36
|
+
}
|
|
37
|
+
function paths(){ return Array.prototype.slice.call(svg.querySelectorAll('path')); }
|
|
38
|
+
|
|
39
|
+
var layers = [];
|
|
40
|
+
map.models.forEach(function(m){ (layers[m.layer] = layers[m.layer] || []).push(m); });
|
|
41
|
+
layers.forEach(function(models, d){
|
|
42
|
+
if (!models) return;
|
|
43
|
+
if (compact){
|
|
44
|
+
// hubs first: the model that feeds the most is the one worth a look
|
|
45
|
+
models = models.slice().sort(function(a, b){
|
|
46
|
+
return ((downs[b.id] || []).length - (downs[a.id] || []).length) || (a.id < b.id ? -1 : 1);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
var lvl = el('div', 'lvl'), head = el('div', 'lhead');
|
|
50
|
+
var dash = models.every(function(m){ return m.kind === 'dashboard'; });
|
|
51
|
+
head.textContent = (d === 0 ? 'Sources' : dash ? 'Dashboards' : 'Layer ' + d) + ' · ' + models.length;
|
|
52
|
+
lvl.appendChild(head);
|
|
53
|
+
var strip = compact ? el('div', 'chips') : lvl;
|
|
54
|
+
// compact: a layer shows its top dozen and folds the rest, so the whole
|
|
55
|
+
// map fits a screen; the filter lifts the fold while it is active
|
|
56
|
+
var cap = compact ? COMPACT_PER_LAYER : MAP_PER_LAYER;
|
|
57
|
+
var fold = {cap: cap, on: models.length > cap};
|
|
58
|
+
folds.push(fold);
|
|
59
|
+
models.forEach(function(m, i){
|
|
60
|
+
var g = el('div', 'grp');
|
|
61
|
+
g.dataset.pos = i; g.dataset.fold = folds.length - 1;
|
|
62
|
+
if (i >= cap) g.hidden = true;
|
|
63
|
+
var broken = m.status && m.status !== 'ok' && m.status !== 'star_only';
|
|
64
|
+
var b = el('button', 'mdl' + (m.kind === 'source' ? ' src' : '') + (m.kind === 'dashboard' ? ' bi' : '') + (broken ? ' r' : ''));
|
|
65
|
+
b.type = 'button'; b.dataset.id = m.id; b.setAttribute('aria-expanded', 'false');
|
|
66
|
+
var reads = (ups[m.id] || []).length, feeds = (downs[m.id] || []).length;
|
|
67
|
+
// the number a change cares about: how many models read this one
|
|
68
|
+
b.innerHTML = label(m) + (feeds ? ' <small>' + feeds + '</small>' : '');
|
|
69
|
+
b.title = (m.path || m.id) + ' · reads ' + reads + ', feeds ' + feeds + (broken ? ' · ' + m.status.replace('_', ' ') : '');
|
|
70
|
+
b.addEventListener('click', function(){ toggle(m, g, b); });
|
|
71
|
+
b.addEventListener('mouseenter', function(){ if (!pinned) light(m.id); });
|
|
72
|
+
b.addEventListener('focus', function(){ if (!pinned) light(m.id); });
|
|
73
|
+
b.addEventListener('mouseleave', function(){ if (!pinned) unlight(); });
|
|
74
|
+
b.addEventListener('blur', function(){ if (!pinned) unlight(); });
|
|
75
|
+
g.appendChild(b); chips[m.id] = b; groups[m.id] = g; strip.appendChild(g);
|
|
76
|
+
});
|
|
77
|
+
if (compact) lvl.appendChild(strip);
|
|
78
|
+
if (fold.on){
|
|
79
|
+
var more = el('button', 'more-chips'); more.type = 'button';
|
|
80
|
+
more.textContent = '+ ' + (models.length - cap) + ' more';
|
|
81
|
+
more.addEventListener('click', function(){
|
|
82
|
+
fold.on = false;
|
|
83
|
+
Array.prototype.slice.call(lvl.querySelectorAll('.grp')).forEach(function(x){ x.hidden = false; });
|
|
84
|
+
more.remove(); draw();
|
|
85
|
+
});
|
|
86
|
+
lvl.appendChild(more);
|
|
87
|
+
}
|
|
88
|
+
inner.appendChild(lvl);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
function toggle(m, g, b){
|
|
92
|
+
var open = g.querySelector('.cols');
|
|
93
|
+
if (open){
|
|
94
|
+
open.remove(); b.setAttribute('aria-expanded', 'false'); g.classList.remove('open');
|
|
95
|
+
if (pinned === m.id){ pinned = null; unlight(); }
|
|
96
|
+
draw(); return;
|
|
97
|
+
}
|
|
98
|
+
var cols = el('div', 'cols');
|
|
99
|
+
m.columns.slice(0, MAP_COLUMNS).forEach(function(c){
|
|
100
|
+
var s = el('button', 'node in'); s.type = 'button'; s.textContent = c;
|
|
101
|
+
s.title = 'ask about ' + m.id + '.' + c;
|
|
102
|
+
s.addEventListener('click', function(){ opts.onAsk(m.id + '.' + c); });
|
|
103
|
+
cols.appendChild(s);
|
|
104
|
+
});
|
|
105
|
+
var note = null;
|
|
106
|
+
if (m.columns.length > MAP_COLUMNS) note = '+ ' + (m.columns.length - MAP_COLUMNS) + ' more, ask by name';
|
|
107
|
+
else if (!m.declared && m.kind === 'source') note = (m.columns.length ? 'only the columns this project names; the full list is' : 'no column list; it is') +
|
|
108
|
+
' not declared in the project (a select * reads it unnamed). Coverage below adds it.';
|
|
109
|
+
else if (!m.columns.length) note = 'no columns known';
|
|
110
|
+
if (note){ var n = el('span', 'gname'); n.textContent = note; cols.appendChild(n); }
|
|
111
|
+
g.appendChild(cols); g.classList.add('open'); b.setAttribute('aria-expanded', 'true');
|
|
112
|
+
// a click keeps the chain lit so the mouse can travel to the columns
|
|
113
|
+
if (pinned && pinned !== m.id){ var was = groups[pinned].querySelector('.cols'); if (was){ was.remove(); groups[pinned].classList.remove('open'); chips[pinned].setAttribute('aria-expanded', 'false'); } }
|
|
114
|
+
pinned = m.id; light(m.id); draw();
|
|
115
|
+
}
|
|
116
|
+
function light(id){
|
|
117
|
+
var up = walk(ups, id), down = walk(downs, id);
|
|
118
|
+
lit = {}; lit[id] = true;
|
|
119
|
+
Object.keys(up).forEach(function(k){ lit[k] = true; });
|
|
120
|
+
Object.keys(down).forEach(function(k){ lit[k] = true; });
|
|
121
|
+
root.classList.add('focus');
|
|
122
|
+
Object.keys(chips).forEach(function(k){
|
|
123
|
+
var c = chips[k].classList;
|
|
124
|
+
c.toggle('on', !!lit[k]); c.toggle('sel', k === id); c.toggle('up', !!up[k]); c.toggle('down', !!down[k]);
|
|
125
|
+
// a chain member behind a layer's fold comes out while the chain is lit
|
|
126
|
+
if (compact && lit[k]) groups[k].hidden = false;
|
|
127
|
+
});
|
|
128
|
+
if (compact) return;
|
|
129
|
+
paths().forEach(function(p){ p.classList.toggle('on', !!(lit[p.dataset.from] && lit[p.dataset.to])); });
|
|
130
|
+
}
|
|
131
|
+
function unlight(){
|
|
132
|
+
lit = null; root.classList.remove('focus');
|
|
133
|
+
Object.keys(chips).forEach(function(k){ chips[k].classList.remove('on', 'sel', 'up', 'down'); });
|
|
134
|
+
if (compact) refold(); else paths().forEach(function(p){ p.classList.remove('on'); });
|
|
135
|
+
}
|
|
136
|
+
var filterText = '';
|
|
137
|
+
function refold(){
|
|
138
|
+
if (filterText) return;
|
|
139
|
+
Object.keys(groups).forEach(function(k){
|
|
140
|
+
var g = groups[k], fold = folds[g.dataset.fold];
|
|
141
|
+
g.hidden = fold.on && Number(g.dataset.pos) >= fold.cap;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
root.addEventListener('keydown', function(ev){ if (ev.key === 'Escape' && pinned){ var g = groups[pinned]; toggle(map.models.filter(function(m){ return m.id === pinned; })[0], g, chips[pinned]); } });
|
|
145
|
+
|
|
146
|
+
function draw(){
|
|
147
|
+
svg.innerHTML = '';
|
|
148
|
+
if (compact || root.hidden || window.innerWidth <= 680) return;
|
|
149
|
+
var base = inner.getBoundingClientRect();
|
|
150
|
+
svg.setAttribute('width', inner.scrollWidth); svg.setAttribute('height', inner.scrollHeight);
|
|
151
|
+
map.links.forEach(function(l){
|
|
152
|
+
var from = chips[l.src], to = chips[l.dst];
|
|
153
|
+
if (!from || !to || from.parentNode.hidden || to.parentNode.hidden) return;
|
|
154
|
+
var ra = from.getBoundingClientRect(), rb = to.getBoundingClientRect();
|
|
155
|
+
var x1 = ra.right - base.left, y1 = ra.top + ra.height / 2 - base.top;
|
|
156
|
+
var x2 = rb.left - base.left, y2 = rb.top + rb.height / 2 - base.top;
|
|
157
|
+
if (x2 < x1){ x1 = ra.left - base.left; x2 = rb.right - base.left; }
|
|
158
|
+
var dx = Math.max(18, Math.abs(x2 - x1) / 2);
|
|
159
|
+
var p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
160
|
+
p.setAttribute('d', 'M' + x1 + ' ' + y1 + ' C' + (x1 + dx) + ' ' + y1 + ' ' + (x2 - dx) + ' ' + y2 + ' ' + x2 + ' ' + y2);
|
|
161
|
+
p.dataset.from = l.src; p.dataset.to = l.dst;
|
|
162
|
+
var cls = (l.review ? 'r ' : '') + (lit && lit[l.src] && lit[l.dst] ? 'on' : '');
|
|
163
|
+
if (cls.trim()) p.setAttribute('class', cls.trim());
|
|
164
|
+
svg.appendChild(p);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
function filter(text){
|
|
168
|
+
text = (text || '').toLowerCase();
|
|
169
|
+
filterText = text;
|
|
170
|
+
root.classList.toggle('filter', !!text);
|
|
171
|
+
var hidden = 0;
|
|
172
|
+
map.models.forEach(function(m){
|
|
173
|
+
var hit = !!text && (m.id.toLowerCase().indexOf(text) >= 0 ||
|
|
174
|
+
m.columns.some(function(c){ return (m.id + '.' + c).toLowerCase().indexOf(text) >= 0; }));
|
|
175
|
+
chips[m.id].classList.toggle('hit', hit);
|
|
176
|
+
// compact: what does not match leaves the page instead of fading on it,
|
|
177
|
+
// and a match past a layer's fold comes out from under it
|
|
178
|
+
if (compact){
|
|
179
|
+
var g = groups[m.id], off = !!text && !hit;
|
|
180
|
+
g.classList.toggle('miss', off); if (off) hidden += 1;
|
|
181
|
+
var fold = folds[g.dataset.fold];
|
|
182
|
+
g.hidden = !text && fold.on && Number(g.dataset.pos) >= fold.cap && !(lit && lit[m.id]);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
if (opts.onFilter) opts.onFilter(text ? hidden : 0);
|
|
186
|
+
draw();
|
|
187
|
+
}
|
|
188
|
+
return {draw: draw, filter: filter, compact: compact};
|
|
189
|
+
}
|
|
190
|
+
// EXPLORE END
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
// EXPLORE START
|
|
2
|
-
// The map: every model in its layer, the model-level links between them,
|
|
3
|
-
// and a model's columns when it is clicked. Pure DOM over the map the
|
|
4
|
-
// server embeds; nothing here fetches. Hover or focus a model to light its
|
|
5
|
-
// whole chain, upstream and down. Past a cap, links draw only for the lit
|
|
6
|
-
// chain so a large repo stays readable.
|
|
7
|
-
var MAP_PER_LAYER = 40, MAP_WIRE_CAP = 400, MAP_COLUMNS = 60;
|
|
8
|
-
|
|
9
|
-
function renderMap(root, map, opts){
|
|
10
|
-
var inner = root.querySelector('.board-inner'), svg = root.querySelector('.wires');
|
|
11
|
-
var esc = opts.esc, many = map.links.length > MAP_WIRE_CAP;
|
|
12
|
-
var ups = {}, downs = {}, chips = {}, lit = null;
|
|
13
|
-
map.links.forEach(function(l){
|
|
14
|
-
(ups[l.dst] = ups[l.dst] || []).push(l.src);
|
|
15
|
-
(downs[l.src] = downs[l.src] || []).push(l.dst);
|
|
16
|
-
});
|
|
17
|
-
function el(tag, cls){ var e = document.createElement(tag); if (cls) e.className = cls; return e; }
|
|
18
|
-
function label(m){
|
|
19
|
-
if (m.kind !== 'dashboard') return esc(m.id);
|
|
20
|
-
var kind = m.id.split(':')[0];
|
|
21
|
-
return esc(m.id.slice(kind.length + 1)) + ' <small>' + esc(kind) + '</small>';
|
|
22
|
-
}
|
|
23
|
-
function chain(id){
|
|
24
|
-
var seen = {}; seen[id] = true;
|
|
25
|
-
[ups, downs].forEach(function(dir){
|
|
26
|
-
var queue = [id];
|
|
27
|
-
while (queue.length){
|
|
28
|
-
var cur = queue.shift();
|
|
29
|
-
(dir[cur] || []).forEach(function(n){ if (!seen[n]){ seen[n] = true; queue.push(n); } });
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
return seen;
|
|
33
|
-
}
|
|
34
|
-
function paths(){ return Array.prototype.slice.call(svg.querySelectorAll('path')); }
|
|
35
|
-
|
|
36
|
-
var layers = [];
|
|
37
|
-
map.models.forEach(function(m){ (layers[m.layer] = layers[m.layer] || []).push(m); });
|
|
38
|
-
layers.forEach(function(models, d){
|
|
39
|
-
if (!models) return;
|
|
40
|
-
var lvl = el('div', 'lvl'), head = el('div', 'lhead');
|
|
41
|
-
var dash = models.every(function(m){ return m.kind === 'dashboard'; });
|
|
42
|
-
head.textContent = (d === 0 ? 'Sources' : dash ? 'Dashboards' : 'Layer ' + d) + ' · ' + models.length;
|
|
43
|
-
lvl.appendChild(head);
|
|
44
|
-
models.forEach(function(m, i){
|
|
45
|
-
var g = el('div', 'grp');
|
|
46
|
-
if (i >= MAP_PER_LAYER) g.hidden = true;
|
|
47
|
-
var broken = m.status && m.status !== 'ok' && m.status !== 'star_only';
|
|
48
|
-
var b = el('button', 'mdl' + (m.kind === 'source' ? ' src' : '') + (m.kind === 'dashboard' ? ' bi' : '') + (broken ? ' r' : ''));
|
|
49
|
-
b.type = 'button'; b.dataset.id = m.id; b.setAttribute('aria-expanded', 'false');
|
|
50
|
-
// a source's count would be the columns some model names, not the table's width
|
|
51
|
-
b.innerHTML = label(m) + (m.declared && m.kind !== 'source' && m.columns.length ? ' <small>' + m.columns.length + '</small>' : '');
|
|
52
|
-
var reads = (ups[m.id] || []).length, feeds = (downs[m.id] || []).length;
|
|
53
|
-
b.title = (m.path || m.id) + ' · reads ' + reads + ', feeds ' + feeds + (broken ? ' · ' + m.status.replace('_', ' ') : '');
|
|
54
|
-
b.addEventListener('click', function(){ toggle(m, g, b); });
|
|
55
|
-
b.addEventListener('mouseenter', function(){ focus(m.id); });
|
|
56
|
-
b.addEventListener('focus', function(){ focus(m.id); });
|
|
57
|
-
b.addEventListener('mouseleave', unfocus);
|
|
58
|
-
b.addEventListener('blur', unfocus);
|
|
59
|
-
g.appendChild(b); chips[m.id] = b; lvl.appendChild(g);
|
|
60
|
-
});
|
|
61
|
-
if (models.length > MAP_PER_LAYER){
|
|
62
|
-
var more = el('button', 'more-chips'); more.type = 'button';
|
|
63
|
-
more.textContent = '+ ' + (models.length - MAP_PER_LAYER) + ' more';
|
|
64
|
-
more.addEventListener('click', function(){
|
|
65
|
-
Array.prototype.slice.call(lvl.querySelectorAll('.grp')).forEach(function(x){ x.hidden = false; });
|
|
66
|
-
more.remove(); draw();
|
|
67
|
-
});
|
|
68
|
-
lvl.appendChild(more);
|
|
69
|
-
}
|
|
70
|
-
inner.appendChild(lvl);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
function toggle(m, g, b){
|
|
74
|
-
var open = g.querySelector('.cols');
|
|
75
|
-
if (open){ open.remove(); b.setAttribute('aria-expanded', 'false'); draw(); return; }
|
|
76
|
-
var cols = el('div', 'cols');
|
|
77
|
-
m.columns.slice(0, MAP_COLUMNS).forEach(function(c){
|
|
78
|
-
var s = el('button', 'node in'); s.type = 'button'; s.textContent = c;
|
|
79
|
-
s.title = 'ask about ' + m.id + '.' + c;
|
|
80
|
-
s.addEventListener('click', function(){ opts.onAsk(m.id + '.' + c); });
|
|
81
|
-
cols.appendChild(s);
|
|
82
|
-
});
|
|
83
|
-
var note = null;
|
|
84
|
-
if (m.columns.length > MAP_COLUMNS) note = '+ ' + (m.columns.length - MAP_COLUMNS) + ' more, ask by name';
|
|
85
|
-
else if (!m.declared && m.kind === 'source') note = (m.columns.length ? 'only the columns this project names; the full list is' : 'no column list; it is') +
|
|
86
|
-
' not declared in the project (a select * reads it unnamed). Coverage below adds it.';
|
|
87
|
-
else if (!m.columns.length) note = 'no columns known';
|
|
88
|
-
if (note){ var n = el('span', 'gname'); n.textContent = note; cols.appendChild(n); }
|
|
89
|
-
g.appendChild(cols); b.setAttribute('aria-expanded', 'true'); draw();
|
|
90
|
-
}
|
|
91
|
-
function focus(id){
|
|
92
|
-
lit = chain(id); root.classList.add('focus');
|
|
93
|
-
Object.keys(chips).forEach(function(k){ chips[k].classList.toggle('on', !!lit[k]); });
|
|
94
|
-
if (many) draw();
|
|
95
|
-
else paths().forEach(function(p){ p.classList.toggle('on', !!(lit[p.dataset.from] && lit[p.dataset.to])); });
|
|
96
|
-
}
|
|
97
|
-
function unfocus(){
|
|
98
|
-
lit = null; root.classList.remove('focus');
|
|
99
|
-
Object.keys(chips).forEach(function(k){ chips[k].classList.remove('on'); });
|
|
100
|
-
if (many) draw();
|
|
101
|
-
else paths().forEach(function(p){ p.classList.remove('on'); });
|
|
102
|
-
}
|
|
103
|
-
function draw(){
|
|
104
|
-
svg.innerHTML = '';
|
|
105
|
-
if (root.hidden || window.innerWidth <= 680 || (many && !lit)) return;
|
|
106
|
-
var base = inner.getBoundingClientRect();
|
|
107
|
-
svg.setAttribute('width', inner.scrollWidth); svg.setAttribute('height', inner.scrollHeight);
|
|
108
|
-
map.links.forEach(function(l){
|
|
109
|
-
if (many && !(lit[l.src] && lit[l.dst])) return;
|
|
110
|
-
var from = chips[l.src], to = chips[l.dst];
|
|
111
|
-
if (!from || !to || from.parentNode.hidden || to.parentNode.hidden) return;
|
|
112
|
-
var ra = from.getBoundingClientRect(), rb = to.getBoundingClientRect();
|
|
113
|
-
var x1 = ra.right - base.left, y1 = ra.top + ra.height / 2 - base.top;
|
|
114
|
-
var x2 = rb.left - base.left, y2 = rb.top + rb.height / 2 - base.top;
|
|
115
|
-
if (x2 < x1){ x1 = ra.left - base.left; x2 = rb.right - base.left; }
|
|
116
|
-
var dx = Math.max(18, Math.abs(x2 - x1) / 2);
|
|
117
|
-
var p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
118
|
-
p.setAttribute('d', 'M' + x1 + ' ' + y1 + ' C' + (x1 + dx) + ' ' + y1 + ' ' + (x2 - dx) + ' ' + y2 + ' ' + x2 + ' ' + y2);
|
|
119
|
-
p.dataset.from = l.src; p.dataset.to = l.dst;
|
|
120
|
-
var cls = (l.review ? 'r ' : '') + (lit && lit[l.src] && lit[l.dst] ? 'on' : '');
|
|
121
|
-
if (cls.trim()) p.setAttribute('class', cls.trim());
|
|
122
|
-
svg.appendChild(p);
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
function filter(text){
|
|
126
|
-
text = (text || '').toLowerCase();
|
|
127
|
-
root.classList.toggle('filter', !!text);
|
|
128
|
-
map.models.forEach(function(m){
|
|
129
|
-
var hit = !!text && (m.id.toLowerCase().indexOf(text) >= 0 ||
|
|
130
|
-
m.columns.some(function(c){ return (m.id + '.' + c).toLowerCase().indexOf(text) >= 0; }));
|
|
131
|
-
chips[m.id].classList.toggle('hit', hit);
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
return {draw: draw, filter: filter, many: many};
|
|
135
|
-
}
|
|
136
|
-
// EXPLORE END
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|