plot3 0.6.4__tar.gz → 0.6.6__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.
Files changed (81) hide show
  1. {plot3-0.6.4 → plot3-0.6.6}/PKG-INFO +16 -1
  2. {plot3-0.6.4 → plot3-0.6.6}/README.md +15 -0
  3. plot3-0.6.6/plot3/__version__.py +1 -0
  4. {plot3-0.6.4 → plot3-0.6.6}/plot3/jupyter.py +29 -0
  5. {plot3-0.6.4 → plot3-0.6.6}/plot3/masking.py +132 -0
  6. {plot3-0.6.4 → plot3-0.6.6}/plot3/viewer.py +106 -8
  7. {plot3-0.6.4 → plot3-0.6.6}/plot3.egg-info/PKG-INFO +16 -1
  8. {plot3-0.6.4 → plot3-0.6.6}/tests/test_masking.py +41 -0
  9. plot3-0.6.4/plot3/__version__.py +0 -1
  10. {plot3-0.6.4 → plot3-0.6.6}/LICENSE +0 -0
  11. {plot3-0.6.4 → plot3-0.6.6}/plot3/__init__.py +0 -0
  12. {plot3-0.6.4 → plot3-0.6.6}/plot3/aesexpr.py +0 -0
  13. {plot3-0.6.4 → plot3-0.6.6}/plot3/build.py +0 -0
  14. {plot3-0.6.4 → plot3-0.6.6}/plot3/calculus.py +0 -0
  15. {plot3-0.6.4 → plot3-0.6.6}/plot3/compose.py +0 -0
  16. {plot3-0.6.4 → plot3-0.6.6}/plot3/contour.py +0 -0
  17. {plot3-0.6.4 → plot3-0.6.6}/plot3/craft.py +0 -0
  18. {plot3-0.6.4 → plot3-0.6.6}/plot3/encode.py +0 -0
  19. {plot3-0.6.4 → plot3-0.6.6}/plot3/expr.py +0 -0
  20. {plot3-0.6.4 → plot3-0.6.6}/plot3/flip.py +0 -0
  21. {plot3-0.6.4 → plot3-0.6.6}/plot3/function.py +0 -0
  22. {plot3-0.6.4 → plot3-0.6.6}/plot3/geoms.py +0 -0
  23. {plot3-0.6.4 → plot3-0.6.6}/plot3/ggplot.py +0 -0
  24. {plot3-0.6.4 → plot3-0.6.6}/plot3/io.py +0 -0
  25. {plot3-0.6.4 → plot3-0.6.6}/plot3/latexin.py +0 -0
  26. {plot3-0.6.4 → plot3-0.6.6}/plot3/mathtext.py +0 -0
  27. {plot3-0.6.4 → plot3-0.6.6}/plot3/payload.py +0 -0
  28. {plot3-0.6.4 → plot3-0.6.6}/plot3/remote.py +0 -0
  29. {plot3-0.6.4 → plot3-0.6.6}/plot3/scales.py +0 -0
  30. {plot3-0.6.4 → plot3-0.6.6}/plot3/scaling.py +0 -0
  31. {plot3-0.6.4 → plot3-0.6.6}/plot3/special.py +0 -0
  32. {plot3-0.6.4 → plot3-0.6.6}/plot3/stat2d.py +0 -0
  33. {plot3-0.6.4 → plot3-0.6.6}/plot3/static.py +0 -0
  34. {plot3-0.6.4 → plot3-0.6.6}/plot3/stats3d.py +0 -0
  35. {plot3-0.6.4 → plot3-0.6.6}/plot3/table.py +0 -0
  36. {plot3-0.6.4 → plot3-0.6.6}/plot3/themes.py +0 -0
  37. {plot3-0.6.4 → plot3-0.6.6}/plot3.egg-info/SOURCES.txt +0 -0
  38. {plot3-0.6.4 → plot3-0.6.6}/plot3.egg-info/dependency_links.txt +0 -0
  39. {plot3-0.6.4 → plot3-0.6.6}/plot3.egg-info/requires.txt +0 -0
  40. {plot3-0.6.4 → plot3-0.6.6}/plot3.egg-info/top_level.txt +0 -0
  41. {plot3-0.6.4 → plot3-0.6.6}/pyproject.toml +0 -0
  42. {plot3-0.6.4 → plot3-0.6.6}/setup.cfg +0 -0
  43. {plot3-0.6.4 → plot3-0.6.6}/tests/test_2d_density.py +0 -0
  44. {plot3-0.6.4 → plot3-0.6.6}/tests/test_3d.py +0 -0
  45. {plot3-0.6.4 → plot3-0.6.6}/tests/test_animation.py +0 -0
  46. {plot3-0.6.4 → plot3-0.6.6}/tests/test_annotate.py +0 -0
  47. {plot3-0.6.4 → plot3-0.6.6}/tests/test_api_public.py +0 -0
  48. {plot3-0.6.4 → plot3-0.6.6}/tests/test_array_backend.py +0 -0
  49. {plot3-0.6.4 → plot3-0.6.6}/tests/test_arrow.py +0 -0
  50. {plot3-0.6.4 → plot3-0.6.6}/tests/test_build_contract.py +0 -0
  51. {plot3-0.6.4 → plot3-0.6.6}/tests/test_contour.py +0 -0
  52. {plot3-0.6.4 → plot3-0.6.6}/tests/test_everyday_ggplot2.py +0 -0
  53. {plot3-0.6.4 → plot3-0.6.6}/tests/test_expr.py +0 -0
  54. {plot3-0.6.4 → plot3-0.6.6}/tests/test_facets_compose.py +0 -0
  55. {plot3-0.6.4 → plot3-0.6.6}/tests/test_geom_function.py +0 -0
  56. {plot3-0.6.4 → plot3-0.6.6}/tests/test_geoms_ds.py +0 -0
  57. {plot3-0.6.4 → plot3-0.6.6}/tests/test_ggplot2_extras2.py +0 -0
  58. {plot3-0.6.4 → plot3-0.6.6}/tests/test_ggplot2_parity.py +0 -0
  59. {plot3-0.6.4 → plot3-0.6.6}/tests/test_ggplot_extras.py +0 -0
  60. {plot3-0.6.4 → plot3-0.6.6}/tests/test_grammar.py +0 -0
  61. {plot3-0.6.4 → plot3-0.6.6}/tests/test_latex_input.py +0 -0
  62. {plot3-0.6.4 → plot3-0.6.6}/tests/test_lidar_scene.py +0 -0
  63. {plot3-0.6.4 → plot3-0.6.6}/tests/test_math_layers.py +0 -0
  64. {plot3-0.6.4 → plot3-0.6.6}/tests/test_mathtext.py +0 -0
  65. {plot3-0.6.4 → plot3-0.6.6}/tests/test_payload.py +0 -0
  66. {plot3-0.6.4 → plot3-0.6.6}/tests/test_payload_display.py +0 -0
  67. {plot3-0.6.4 → plot3-0.6.6}/tests/test_point_cloud.py +0 -0
  68. {plot3-0.6.4 → plot3-0.6.6}/tests/test_r_oracle_parity.py +0 -0
  69. {plot3-0.6.4 → plot3-0.6.6}/tests/test_readme.py +0 -0
  70. {plot3-0.6.4 → plot3-0.6.6}/tests/test_remote_bridge.py +0 -0
  71. {plot3-0.6.4 → plot3-0.6.6}/tests/test_review_fixes.py +0 -0
  72. {plot3-0.6.4 → plot3-0.6.6}/tests/test_save_menu.py +0 -0
  73. {plot3-0.6.4 → plot3-0.6.6}/tests/test_scales_api.py +0 -0
  74. {plot3-0.6.4 → plot3-0.6.6}/tests/test_scales_themes.py +0 -0
  75. {plot3-0.6.4 → plot3-0.6.6}/tests/test_special.py +0 -0
  76. {plot3-0.6.4 → plot3-0.6.6}/tests/test_stat2d.py +0 -0
  77. {plot3-0.6.4 → plot3-0.6.6}/tests/test_static_save.py +0 -0
  78. {plot3-0.6.4 → plot3-0.6.6}/tests/test_stats_semantic.py +0 -0
  79. {plot3-0.6.4 → plot3-0.6.6}/tests/test_step3_geoms.py +0 -0
  80. {plot3-0.6.4 → plot3-0.6.6}/tests/test_table_backends.py +0 -0
  81. {plot3-0.6.4 → plot3-0.6.6}/tests/test_titles_theme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plot3
3
- Version: 0.6.4
3
+ Version: 0.6.6
4
4
  Summary: ggplot2 grammar of graphics for Python: interactive WebGL figures and journal-ready PNG, SVG, and PDF
5
5
  Author: Rigoberto Leyva Salmeron
6
6
  License-Expression: MIT
@@ -130,6 +130,10 @@ gapminder = pd.read_csv("https://raw.githubusercontent.com/kirenz/datasets/maste
130
130
  `{frame_time}` in the title shows the current year as it plays.
131
131
  `transition_states("phase")` steps through categories instead of time.
132
132
 
133
+ Click a bubble to trace it: its path through every frame appears, with
134
+ the years along it and its name (from `group=`) riding beside it as it
135
+ plays. Click it again, or empty space, to clear it. This works in 3D too.
136
+
133
137
  <details>
134
138
  <summary>Run this without downloading anything (made-up gapminder-shaped data)</summary>
135
139
 
@@ -256,6 +260,17 @@ In notebooks, bare column names work as in R: `aes(x=dose, y=response)`.
256
260
  - **Jupyter / SolveIt**: bare column names and backticks work in `aes()`,
257
261
  `facet_wrap()`, and `facet_grid()` (`aes(x=`First Name`)`). Toggle with
258
262
  `enable_r_style()` / `disable_r_style()`. `.py` files keep quoted strings.
263
+ - **Layers on their own lines**, as in R, with no parentheses:
264
+
265
+ ```text
266
+ ggplot(gapminder, aes(x=gdpPercap, y=lifeExp, colour=continent))
267
+ + geom_point(alpha=0.7) # comments are fine
268
+ + scale_x_log10()
269
+ ```
270
+
271
+ A line starting with `+` and a plot3 function continues the plot above
272
+ it. Import plot3 in an earlier cell: IPython reads a whole cell before
273
+ running any of it. `.py` files keep the outer parentheses.
259
274
  - **SolveIt** draws figures inline and hides their HTML from the model's
260
275
  context (`autohide(False)` to opt out).
261
276
  - **VS Code notebooks** block WebGL in output cells, so plot3 opens figures
@@ -103,6 +103,10 @@ gapminder = pd.read_csv("https://raw.githubusercontent.com/kirenz/datasets/maste
103
103
  `{frame_time}` in the title shows the current year as it plays.
104
104
  `transition_states("phase")` steps through categories instead of time.
105
105
 
106
+ Click a bubble to trace it: its path through every frame appears, with
107
+ the years along it and its name (from `group=`) riding beside it as it
108
+ plays. Click it again, or empty space, to clear it. This works in 3D too.
109
+
106
110
  <details>
107
111
  <summary>Run this without downloading anything (made-up gapminder-shaped data)</summary>
108
112
 
@@ -229,6 +233,17 @@ In notebooks, bare column names work as in R: `aes(x=dose, y=response)`.
229
233
  - **Jupyter / SolveIt**: bare column names and backticks work in `aes()`,
230
234
  `facet_wrap()`, and `facet_grid()` (`aes(x=`First Name`)`). Toggle with
231
235
  `enable_r_style()` / `disable_r_style()`. `.py` files keep quoted strings.
236
+ - **Layers on their own lines**, as in R, with no parentheses:
237
+
238
+ ```text
239
+ ggplot(gapminder, aes(x=gdpPercap, y=lifeExp, colour=continent))
240
+ + geom_point(alpha=0.7) # comments are fine
241
+ + scale_x_log10()
242
+ ```
243
+
244
+ A line starting with `+` and a plot3 function continues the plot above
245
+ it. Import plot3 in an earlier cell: IPython reads a whole cell before
246
+ running any of it. `.py` files keep the outer parentheses.
232
247
  - **SolveIt** draws figures inline and hides their HTML from the model's
233
248
  context (`autohide(False)` to opt out).
234
249
  - **VS Code notebooks** block WebGL in output cells, so plot3 opens figures
@@ -0,0 +1 @@
1
+ __version__ = "0.6.6"
@@ -22,6 +22,7 @@ from plot3.masking import (
22
22
  is_mask_transformer,
23
23
  is_tidy3_backtick_transformer,
24
24
  plot3_backtick_transform,
25
+ plot3_layer_transform,
25
26
  )
26
27
 
27
28
  # R-style bare-name / backtick masking for aes() / facet_wrap().
@@ -463,6 +464,28 @@ def run_plot3_from_magic(line: str = ""):
463
464
  # ═════════════════════════════════════════════════════════════════════════════
464
465
 
465
466
 
467
+ def enable_layer_lines(ipython: Any | None = None) -> bool:
468
+ """Let ``+ geom_point()`` lines continue the ggplot above them in a cell.
469
+
470
+ ``ggplot(df, aes(...))`` then ``+ geom_point()`` on the next lines, with
471
+ no parentheses, as in R. Only a ``+`` followed by a plot3 name joins, so
472
+ ordinary Python is never rewritten.
473
+ """
474
+ if ipython is None:
475
+ try:
476
+ ipython = get_ipython() if get_ipython is not None else None
477
+ except Exception:
478
+ ipython = None
479
+ transformers = getattr(ipython, "input_transformers_cleanup", None)
480
+ if not isinstance(transformers, list):
481
+ return False
482
+ transformers[:] = [
483
+ t for t in transformers if getattr(t, "__name__", "") != "plot3_layer_transform"
484
+ ]
485
+ transformers.append(plot3_layer_transform)
486
+ return True
487
+
488
+
466
489
  def register_plot3(*, quiet=True, r_style: bool = True) -> bool:
467
490
  if get_ipython is None:
468
491
  return False
@@ -495,6 +518,12 @@ def register_plot3(*, quiet=True, r_style: bool = True) -> bool:
495
518
  ns[name] = getattr(plot3_pkg, name)
496
519
  except Exception:
497
520
  pass
521
+ # ggplot(...) then "+ geom_point()" lines, without parentheses
522
+ try:
523
+ enable_layer_lines(ip)
524
+ except Exception as e:
525
+ if not quiet:
526
+ print(f"plot3: multi-line layers not enabled: {e}")
498
527
  # R-style bare names / backticks in aes() (ggplot2 parity with tidy3)
499
528
  if r_style:
500
529
  try:
@@ -179,6 +179,138 @@ def plot3_backtick_transform(lines: list[str]) -> list[str]:
179
179
  return parts or [out]
180
180
 
181
181
 
182
+ _OPENERS = frozenset("([{")
183
+ _CLOSERS = frozenset(")]}")
184
+ _SKIP_TOKENS = frozenset({
185
+ tokenize.COMMENT, tokenize.NL, tokenize.NEWLINE, tokenize.INDENT,
186
+ tokenize.DEDENT, tokenize.ENCODING, tokenize.ENDMARKER,
187
+ })
188
+
189
+
190
+ def _plot3_names() -> set[str]:
191
+ try:
192
+ import plot3
193
+
194
+ return {str(name) for name in getattr(plot3, "__all__", ())}
195
+ except Exception:
196
+ return set()
197
+
198
+
199
+ def _logical_lines(text: str) -> list[list[tokenize.TokenInfo]] | None:
200
+ """Token lists, one per logical line; ``None`` if *text* cannot tokenize."""
201
+ lines: list[list[tokenize.TokenInfo]] = []
202
+ current: list[tokenize.TokenInfo] = []
203
+ try:
204
+ for token in tokenize.generate_tokens(io.StringIO(text).readline):
205
+ if token.type in _SKIP_TOKENS:
206
+ if token.type == tokenize.NEWLINE and current:
207
+ lines.append(current)
208
+ current = []
209
+ continue
210
+ current.append(token)
211
+ except (tokenize.TokenError, IndentationError, SyntaxError):
212
+ return None
213
+ if current:
214
+ lines.append(current)
215
+ return lines
216
+
217
+
218
+ def _value_start(tokens: list[tokenize.TokenInfo]) -> tuple[int, int]:
219
+ """Where the expression starts: after a top-level ``=``, else the start."""
220
+ depth = 0
221
+ for index, token in enumerate(tokens):
222
+ if token.string in _OPENERS:
223
+ depth += 1
224
+ elif token.string in _CLOSERS:
225
+ depth -= 1
226
+ elif depth == 0 and token.type == tokenize.OP and token.string == "=":
227
+ if index + 1 < len(tokens):
228
+ return tokens[index + 1].start
229
+ return tokens[0].start
230
+
231
+
232
+ def join_layer_lines(source: str) -> str | None:
233
+ """Join ``+`` lines that add plot3 layers to the statement above them.
234
+
235
+ In a notebook, ``ggplot(df, aes(...))`` followed by lines that start
236
+ with ``+ geom_point()`` reads like R, but to Python each ``+`` line is
237
+ a statement of its own (unary plus, or an IndentationError when
238
+ indented). A line whose first token is ``+`` followed by a plot3 name
239
+ continues the top-level statement above it, across comments and blank
240
+ lines; the expression gets parentheses on the lines as typed, so error
241
+ line numbers still match. Returns ``None`` when nothing needs joining,
242
+ or when the result would not parse. Pipes (``>>``) are tidy3's.
243
+ """
244
+ if not source or "+" not in source:
245
+ return None
246
+ raw = source.split("\n")
247
+ if not any(line.lstrip().startswith("+") for line in raw):
248
+ return None
249
+ names = _plot3_names()
250
+ # Indented "+ geom_point()" lines must not trip the tokenizer.
251
+ shift = [len(line) - len(line.lstrip()) if line.lstrip().startswith("+") else 0 for line in raw]
252
+ analysed = "\n".join(line[n:] for line, n in zip(raw, shift))
253
+ logical = _logical_lines(analysed)
254
+ if not logical:
255
+ return None
256
+
257
+ def is_layer(tokens: list[tokenize.TokenInfo]) -> bool:
258
+ return (
259
+ len(tokens) > 1
260
+ and tokens[0].type == tokenize.OP
261
+ and tokens[0].string == "+"
262
+ and tokens[1].type == tokenize.NAME
263
+ and tokens[1].string in names
264
+ )
265
+
266
+ groups: list[list[list[tokenize.TokenInfo]]] = []
267
+ for tokens in logical:
268
+ previous = groups[-1] if groups else None
269
+ head = previous[0][0] if previous else None
270
+ if (
271
+ previous is not None
272
+ and is_layer(tokens)
273
+ and head.start[1] == 0
274
+ and head.type != tokenize.OP
275
+ and head.string not in {"if", "for", "while", "with", "def", "class", "try", "elif", "else", "except", "finally", "return", "import", "from"}
276
+ ):
277
+ previous.append(tokens)
278
+ else:
279
+ groups.append([tokens])
280
+
281
+ edits: list[tuple[int, int, str]] = []
282
+ for group in groups:
283
+ if len(group) < 2:
284
+ continue
285
+ opening = _value_start(group[0])
286
+ closing = group[-1][-1].end
287
+ edits.append((opening[0], opening[1] + shift[opening[0] - 1], "("))
288
+ edits.append((closing[0], closing[1] + shift[closing[0] - 1], ")"))
289
+ if not edits:
290
+ return None
291
+ lines = list(raw)
292
+ for row, col, piece in sorted(edits, reverse=True):
293
+ line = lines[row - 1]
294
+ lines[row - 1] = line[:col] + piece + line[col:]
295
+ rewritten = "\n".join(lines)
296
+ try:
297
+ ast.parse(rewrite_backticks(rewritten))
298
+ except SyntaxError:
299
+ return None
300
+ return rewritten
301
+
302
+
303
+ def plot3_layer_transform(lines: list[str]) -> list[str]:
304
+ """IPython input transformer: ``+`` lines continue a ggplot (see join_layer_lines)."""
305
+ if not lines:
306
+ return lines
307
+ source = "".join(lines)
308
+ out = join_layer_lines(source)
309
+ if out is None:
310
+ return lines
311
+ return out.splitlines(keepends=True)
312
+
313
+
182
314
  def _is_bt_call(node: ast.AST) -> bool:
183
315
  return (
184
316
  isinstance(node, ast.Call)
@@ -702,6 +702,26 @@ const stepEntries = [];
702
702
  let hoverHold = false;
703
703
  let trail = null;
704
704
  let trailId = null;
705
+ // The clicked bubble's path: points with their frame, its name and colour.
706
+ let trailInfo = null;
707
+ // A click toggles a bubble's path only on the bubble itself (a little
708
+ // slack for tiny dots), so a stray click near one draws nothing.
709
+ function clickRadius(L, i, minimum) {
710
+ return L._size ? Math.max(minimum, L._size[i] * 0.55) : minimum;
711
+ }
712
+ function trailKey(L, i) { return String(i) + '|' + (L.ids ? L.ids[i] : ''); }
713
+ function trailHint(L, i) {
714
+ return '<br><span style="opacity:.6;font-size:10px">click to ' +
715
+ (trailId === trailKey(L, i) ? 'hide' : 'show') + ' its path</span>';
716
+ }
717
+ // The frame's value (the year) for a point on the path, or '' if unknown.
718
+ function trailYear(f) {
719
+ const tr = S.transition;
720
+ if (!tr || !tr.times || !trailInfo || tr.times.length !== trailInfo.nF) return '';
721
+ const v = tr.times[f];
722
+ if (tr.integer) return String(Math.round(+v));
723
+ return typeof v === 'number' ? fmt(v) : String(v);
724
+ }
705
725
  function framePresent(F, i) {
706
726
  if (!F.x || !F.x.maskData || !F.x.maskData[i]) return false;
707
727
  if (!F.y || !F.y.maskData || !F.y.maskData[i]) return false;
@@ -1167,6 +1187,7 @@ function clearTrail() {
1167
1187
  trail = null;
1168
1188
  }
1169
1189
  trailId = null;
1190
+ trailInfo = null;
1170
1191
  }
1171
1192
  function setTrail(L, obj) {
1172
1193
  clearTrail();
@@ -1208,7 +1229,18 @@ function setTrail(L, obj) {
1208
1229
  }
1209
1230
  scene.add(group);
1210
1231
  trail = group;
1211
- trailId = String(obj) + '|' + (L.ids ? L.ids[obj] : '');
1232
+ trailId = trailKey(L, obj);
1233
+ const pts = [];
1234
+ for (let f = 0; f < nF; f++) {
1235
+ const i = obj * nF + f;
1236
+ if (!framePresent(F, i)) continue;
1237
+ pts.push({ x: F.x.data[i], y: F.y.data[i], z: F.z ? F.z.data[i] : 0, f: f });
1238
+ }
1239
+ trailInfo = {
1240
+ L: L, obj: obj, nF: nF, ext: ext, pts: pts,
1241
+ name: L.ids && L.ids[obj] != null ? String(L.ids[obj]) : '',
1242
+ color: 'rgb(' + rgb.map(c => Math.round(c * 255)).join(',') + ')',
1243
+ };
1212
1244
  }
1213
1245
  let frameLabel = '';
1214
1246
  const axisTitleSprites = [];
@@ -2038,10 +2070,48 @@ if (!S.is3d) {
2038
2070
  const AY = Math.min(Math.max(Y, M.t + 12), M.t + H - 10);
2039
2071
  s += '<text x="' + AX + '" y="' + AY + '" fill="' + T.ink + '" text-anchor="middle" dominant-baseline="middle" font-size="12" font-weight="600" paint-order="stroke" stroke="' + T.surface + '" stroke-width="4" stroke-linejoin="round">' + plot3Esc(ann.text) + '</text>';
2040
2072
  }
2073
+ if (trailInfo) s += trailLabels2D();
2041
2074
  svg.innerHTML = s;
2042
2075
  if (gridSvg) gridSvg.innerHTML = grid;
2043
2076
  }
2044
2077
 
2078
+ // The clicked bubble's name rides beside it; years mark its path,
2079
+ // spaced so they do not overlap (the first and last always show).
2080
+ function trailLabels2D() {
2081
+ const t = trailInfo, ext = t.ext;
2082
+ const inside = (X, Y) => X >= M.l && X <= M.l + W && Y >= M.t && Y <= M.t + H;
2083
+ const halo = ' paint-order="stroke" stroke="' + T.surface + '" stroke-width="3" stroke-linejoin="round"';
2084
+ // The name sits right of the bubble's current position; years keep clear.
2085
+ const L = t.L, i = t.obj;
2086
+ let nameBox = null, nameSvg = '';
2087
+ if (t.name && L._x && L._y) {
2088
+ const X = M.l + screenX(L._x[i] * ext[0]), Y = M.t + screenY(L._y[i] * ext[1]);
2089
+ if (inside(X, Y)) {
2090
+ const x0 = X + (L._size ? L._size[i] * 0.5 : 4) + 4;
2091
+ nameBox = [x0 - 4, Y - 9, x0 + 7.2 * t.name.length + 4, Y + 9];
2092
+ nameSvg = '<text x="' + x0 + '" y="' + Y + '" fill="' + t.color +
2093
+ '" font-size="12" font-weight="700" dominant-baseline="middle"' + halo + '>' +
2094
+ plot3Esc(t.name) + '</text>';
2095
+ }
2096
+ }
2097
+ const underName = (X, Y) => nameBox && X + 5 + 26 > nameBox[0] && X + 5 < nameBox[2] &&
2098
+ Y - 5 > nameBox[1] - 6 && Y - 5 - 10 < nameBox[3];
2099
+ const kept = [];
2100
+ t.pts.forEach((p, k) => {
2101
+ const X = M.l + screenX(p.x * ext[0]), Y = M.t + screenY(p.y * ext[1]);
2102
+ const label = trailYear(p.f);
2103
+ if (!label || !inside(X, Y) || underName(X, Y)) return;
2104
+ const prev = kept[kept.length - 1];
2105
+ const near = prev && Math.hypot(X - prev.X, Y - prev.Y) < 34;
2106
+ if (near && k !== t.pts.length - 1) return;
2107
+ if (near && kept.length > 1) kept.pop(); // the last year wins a crowded end
2108
+ else if (near) return;
2109
+ kept.push({ X, Y, label });
2110
+ });
2111
+ return kept.map(k => '<text x="' + (k.X + 5) + '" y="' + (k.Y - 5) + '" fill="' + T.muted +
2112
+ '" font-size="10"' + halo + '>' + plot3Esc(k.label) + '</text>').join('') + nameSvg;
2113
+ }
2114
+
2045
2115
  function layout() {
2046
2116
  placeLegend();
2047
2117
  W = Math.max(50, figEl.clientWidth - M.l - M.r);
@@ -2283,7 +2353,9 @@ if (!S.is3d) {
2283
2353
  : head
2284
2354
  + fmtSpan('x', xv, xSpan) + ', '
2285
2355
  + fmtSpan('y', yv, ySpan) + sizeBit;
2286
- consider(d, sx, sy, html, !!(L.kind === 'point' && L.frames && S.transition));
2356
+ const moving = L.kind === 'point' && L.frames && S.transition;
2357
+ const cr = clickRadius(L, i, 6);
2358
+ consider(d, sx, sy, html + (moving && d <= cr * cr ? trailHint(L, i) : ''), !!moving);
2287
2359
  }
2288
2360
  }
2289
2361
  }
@@ -2311,14 +2383,14 @@ if (!S.is3d) {
2311
2383
  for (let i = 0; i < L.n; i++) {
2312
2384
  if (L._alpha && L._alpha[i] <= 0.04) continue;
2313
2385
  const sx = screenX(L._x[i]), sy = screenY(L._y[i]);
2314
- const rad = L._size ? Math.max(10, L._size[i] * 0.55) : 12;
2386
+ const rad = clickRadius(L, i, 6);
2315
2387
  const d = (sx - mx) * (sx - mx) + (sy - my) * (sy - my);
2316
2388
  if (d <= rad * rad && d < hitD) { hitD = d; hit = { L, i }; }
2317
2389
  }
2318
2390
  }
2391
+ tip.style.display = 'none';
2319
2392
  if (!hit) { clearTrail(); draw(); return; }
2320
- const key = String(hit.i) + '|' + (hit.L.ids ? hit.L.ids[hit.i] : '');
2321
- if (trailId === key) { clearTrail(); draw(); return; }
2393
+ if (trailId === trailKey(hit.L, hit.i)) { clearTrail(); draw(); return; }
2322
2394
  setTrail(hit.L, hit.i);
2323
2395
  draw();
2324
2396
  });
@@ -2660,6 +2732,7 @@ if (!S.is3d) {
2660
2732
  }
2661
2733
  }
2662
2734
  hoverHold = !!(best && best[0].kind === 'point' && best[0].frames && S.transition);
2735
+ const hoverD = bestD;
2663
2736
  if (!best) { tip3.style.display = 'none'; return; }
2664
2737
  const [L, i, sx, sy, xn, yn, zn] = best;
2665
2738
  const xv = fromScale('x', xn);
@@ -2687,6 +2760,10 @@ if (!S.is3d) {
2687
2760
  + axisPair('y', fmt(yv)) + ', '
2688
2761
  + axisPair('z', fmt(zv)) + sizeBit
2689
2762
  : head + fmt(xv) + ', ' + fmt(yv) + ', ' + fmt(zv) + sizeBit;
2763
+ if (hoverHold) {
2764
+ const cr = sizeAtten ? 8 : clickRadius(L, i, 8);
2765
+ if (hoverD <= cr * cr) tip3.innerHTML += trailHint(L, i);
2766
+ }
2690
2767
  plot3Typeset(tip3);
2691
2768
  tip3.style.left = Math.min(w - 8, sx + 12) + 'px';
2692
2769
  tip3.style.top = Math.max(8, sy - 10) + 'px';
@@ -2737,15 +2814,36 @@ if (!S.is3d) {
2737
2814
  v.project(cam);
2738
2815
  const sx = (v.x * 0.5 + 0.5) * w;
2739
2816
  const sy = (-v.y * 0.5 + 0.5) * h;
2740
- const rad = (L._size && !sizeAtten) ? Math.max(16, L._size[i] * 0.55) : 16;
2817
+ const rad = sizeAtten ? 8 : clickRadius(L, i, 8);
2741
2818
  const d = (sx - mx) * (sx - mx) + (sy - my) * (sy - my);
2742
2819
  if (d <= rad * rad && d < hitD) { hitD = d; hit = { L, i }; }
2743
2820
  }
2744
2821
  }
2822
+ tip3.style.display = 'none';
2745
2823
  if (!hit) { clearTrail(); return; }
2746
- const key = String(hit.i) + '|' + (hit.L.ids ? hit.L.ids[hit.i] : '');
2747
- if (trailId === key) { clearTrail(); return; }
2824
+ if (trailId === trailKey(hit.L, hit.i)) { clearTrail(); return; }
2748
2825
  setTrail(hit.L, hit.i);
2826
+ // The scene cannot move text every frame cheaply: label the path's
2827
+ // ends, the first year, and the name with the last year.
2828
+ if (trail && trailInfo && trailInfo.pts.length) {
2829
+ const t = trailInfo;
2830
+ const at = p => new THREE.Vector3(p.x * t.ext[0], p.y * t.ext[1], p.z * t.ext[2]);
2831
+ const first = t.pts[0], last = t.pts[t.pts.length - 1];
2832
+ const startText = trailYear(first.f);
2833
+ if (startText) {
2834
+ const sp = sprite(startText, true);
2835
+ sp.center.set(-0.1, -0.2);
2836
+ sp.position.copy(at(first));
2837
+ trail.add(sp);
2838
+ }
2839
+ const endText = [t.name, trailYear(last.f)].filter(Boolean).join(' ');
2840
+ if (endText) {
2841
+ const sp = sprite(endText, false);
2842
+ sp.center.set(-0.1, -0.2);
2843
+ sp.position.copy(at(last));
2844
+ trail.add(sp);
2845
+ }
2846
+ }
2749
2847
  });
2750
2848
 
2751
2849
  function layout() {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plot3
3
- Version: 0.6.4
3
+ Version: 0.6.6
4
4
  Summary: ggplot2 grammar of graphics for Python: interactive WebGL figures and journal-ready PNG, SVG, and PDF
5
5
  Author: Rigoberto Leyva Salmeron
6
6
  License-Expression: MIT
@@ -130,6 +130,10 @@ gapminder = pd.read_csv("https://raw.githubusercontent.com/kirenz/datasets/maste
130
130
  `{frame_time}` in the title shows the current year as it plays.
131
131
  `transition_states("phase")` steps through categories instead of time.
132
132
 
133
+ Click a bubble to trace it: its path through every frame appears, with
134
+ the years along it and its name (from `group=`) riding beside it as it
135
+ plays. Click it again, or empty space, to clear it. This works in 3D too.
136
+
133
137
  <details>
134
138
  <summary>Run this without downloading anything (made-up gapminder-shaped data)</summary>
135
139
 
@@ -256,6 +260,17 @@ In notebooks, bare column names work as in R: `aes(x=dose, y=response)`.
256
260
  - **Jupyter / SolveIt**: bare column names and backticks work in `aes()`,
257
261
  `facet_wrap()`, and `facet_grid()` (`aes(x=`First Name`)`). Toggle with
258
262
  `enable_r_style()` / `disable_r_style()`. `.py` files keep quoted strings.
263
+ - **Layers on their own lines**, as in R, with no parentheses:
264
+
265
+ ```text
266
+ ggplot(gapminder, aes(x=gdpPercap, y=lifeExp, colour=continent))
267
+ + geom_point(alpha=0.7) # comments are fine
268
+ + scale_x_log10()
269
+ ```
270
+
271
+ A line starting with `+` and a plot3 function continues the plot above
272
+ it. Import plot3 in an earlier cell: IPython reads a whole cell before
273
+ running any of it. `.py` files keep the outer parentheses.
259
274
  - **SolveIt** draws figures inline and hides their HTML from the model's
260
275
  context (`autohide(False)` to opt out).
261
276
  - **VS Code notebooks** block WebGL in output cells, so plot3 opens figures
@@ -197,3 +197,44 @@ def test_aes_expression_over_columns_passes_whole():
197
197
  # Notebook variables and modules keep the old behaviour.
198
198
  assert apply_masking("aes(x=np.log(x))", known=known) == "aes(x=np.log('x'))"
199
199
  assert apply_masking("aes(x=wt)", known=known) == "aes(x='wt')"
200
+
201
+
202
+ def test_plus_lines_continue_a_ggplot():
203
+ # ggplot(...) then "+ geom_point()" lines, as in R: to Python each "+"
204
+ # line is its own statement (unary plus, or an IndentationError).
205
+ import ast
206
+
207
+ from plot3.masking import join_layer_lines
208
+
209
+ cell = (
210
+ 'ggplot(df, aes(x="a", y="b"))\n'
211
+ ' + geom_point(alpha=0.7)\n'
212
+ ' + scale_x_log10()'
213
+ )
214
+ joined = join_layer_lines(cell)
215
+ assert joined == (
216
+ '(ggplot(df, aes(x="a", y="b"))\n'
217
+ ' + geom_point(alpha=0.7)\n'
218
+ ' + scale_x_log10())'
219
+ )
220
+ tree = ast.parse(joined)
221
+ assert len(tree.body) == 1
222
+
223
+ # An assignment, with comments and a blank line between layers.
224
+ cell = 'p = ggplot(df) # base\n# layers\n+ geom_point() # points\n\n+ theme_bw()\np'
225
+ joined = join_layer_lines(cell)
226
+ assert joined.startswith("p = (ggplot(df)") and "+ theme_bw())" in joined
227
+ assert len(ast.parse(joined).body) == 2
228
+
229
+ # patchwork: a composition takes "+ plot_layout(...)" lines too.
230
+ assert join_layer_lines("fig = (a | b)\n+ plot_layout(widths=[3, 2])").startswith("fig = ((a | b)")
231
+
232
+
233
+ def test_plus_lines_leave_other_python_alone():
234
+ from plot3.masking import join_layer_lines
235
+
236
+ assert join_layer_lines("x = 1\n+2") is None # unary plus
237
+ assert join_layer_lines("y = 1\n+ foo(2)") is None # not a plot3 name
238
+ assert join_layer_lines("df\n>> ggplot()\n+ geom_point()") is None # tidy3's pipe
239
+ assert join_layer_lines("for i in r:\n p = ggplot(df)\n + geom_point()") is None
240
+ assert join_layer_lines("p = ggplot(df) + geom_point()") is None # already one line
@@ -1 +0,0 @@
1
- __version__ = "0.6.4"
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