Crocolil commited on
Commit
3fa67e8
·
1 Parent(s): 5901a95

Allow hand-drawn links between neighboring plants on the garden board

Browse files

Add a "Relier des plantes" link mode: click two sprites to connect
(or disconnect) them. Linked pairs are stored bidirectionally as
neighbors, drawn as dashed lines on the board, and surfaced to the
gardening advisor so it can factor in companion-planting effects.

Files changed (3) hide show
  1. app.py +101 -5
  2. modules/advisor.py +13 -3
  3. static/style.css +35 -0
app.py CHANGED
@@ -52,6 +52,24 @@ LON = 5.3698
52
  BOARD_JS = """
53
  <script>
54
  (function () {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  function attachBoard() {
56
  const board = document.getElementById('garden-board');
57
  if (!board || board._wired) return;
@@ -107,6 +125,21 @@ BOARD_JS = """
107
  window._gardenMove = { idx: drag.idx, x: x, y: y };
108
  const btn = document.getElementById('garden-move-btn');
109
  if (btn) btn.click();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  } else {
111
  window._gardenSelect = { idx: drag.idx };
112
  const btn = document.getElementById('garden-select-btn');
@@ -118,10 +151,15 @@ BOARD_JS = """
118
  board.addEventListener('pointercancel', () => { drag = null; });
119
  }
120
 
121
- const observer = new MutationObserver(attachBoard);
 
 
 
 
 
122
  observer.observe(document.body, { childList: true, subtree: true });
123
- document.addEventListener('DOMContentLoaded', attachBoard);
124
- attachBoard();
125
  })();
126
  </script>
127
  """
@@ -293,6 +331,7 @@ def get_garden_board_html(user_id: str) -> str:
293
  garden = load_garden(user_id)
294
  today = datetime.date.today()
295
  tiles = []
 
296
  for idx, p in enumerate(garden):
297
  sprite_path = Path(pixel_art.get_sprite_path(p["genus"])).resolve()
298
  sprite_url = f"/gradio_api/file={sprite_path.as_posix()}"
@@ -310,13 +349,31 @@ def get_garden_board_html(user_id: str) -> str:
310
  caption += " 💧"
311
 
312
  pos = p.get("position") or _default_position(idx)
 
313
  tiles.append(
314
  f'<div class="garden-sprite" data-idx="{idx}" style="left:{pos["x"]}%; top:{pos["y"]}%;">'
315
  f'<img src="{sprite_url}" draggable="false" alt="{html.escape(caption)}">'
316
  f'<div class="garden-caption">{html.escape(caption)}</div>'
317
  f'</div>'
318
  )
319
- return f'<div class="garden-board-inner">{"".join(tiles)}</div>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
 
321
  def _visible_plants(user_id: str) -> list[dict]:
322
  """All garden plants, in the same order as the gallery."""
@@ -500,7 +557,9 @@ with gr.Blocks(title="🌿 Plant Watering Planner") as app:
500
  with gr.Row():
501
 
502
  with gr.Column(scale=3):
503
- gr.Markdown("_Drag a plant to place it anywhere in your garden, or click it to see details._")
 
 
504
 
505
  # Garden board — freely draggable pixel-art sprites
506
  garden_board = gr.HTML(value="", elem_id="garden-board")
@@ -513,6 +572,9 @@ with gr.Blocks(title="🌿 Plant Watering Planner") as app:
513
  board_move_x = gr.Number(value=0, visible=False)
514
  board_move_y = gr.Number(value=0, visible=False)
515
  board_move_btn = gr.Button("sync", elem_id="garden-move-btn", elem_classes=["board-sync"])
 
 
 
516
 
517
  # Detail panel — appears on click
518
  plant_detail = gr.Markdown("_Click a plant photo to see its details._", elem_id="plant-detail")
@@ -606,6 +668,30 @@ with gr.Blocks(title="🌿 Plant Watering Planner") as app:
606
  js="(idx, x, y, user_id) => { const d = window._gardenMove || {idx: -1, x: 0, y: 0}; return [d.idx, d.x, d.y, user_id]; }",
607
  )
608
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
  def _mark_watered_by_idx(idx, user_id):
610
  if idx is None:
611
  return "⚠️ Select a plant first.", get_garden_board_html(user_id)
@@ -631,6 +717,9 @@ with gr.Blocks(title="🌿 Plant Watering Planner") as app:
631
  return "⚠️ Plant not found.", get_garden_board_html(user_id)
632
  target_id = visible[idx]["id"]
633
  garden = [p for p in load_garden(user_id) if p.get("id") != target_id]
 
 
 
634
  save_garden(garden, user_id)
635
  return "🗑️ Plant removed.", get_garden_board_html(user_id)
636
 
@@ -657,11 +746,18 @@ with gr.Blocks(title="🌿 Plant Watering Planner") as app:
657
  return "Select a plant first."
658
  plant = plants[idx]
659
  info = get_plant_info(plant["genus"])
 
 
 
 
 
 
660
  return advisor.ask_about_plant(
661
  question, info,
662
  plant_name=plant.get("nickname"),
663
  genus=plant["genus"],
664
  last_watered=plant.get("last_watered"),
 
665
  )
666
 
667
  advisor_ask_btn.click(
 
52
  BOARD_JS = """
53
  <script>
54
  (function () {
55
+ function attachLinkModeBtn() {
56
+ const linkBtn = document.getElementById('link-mode-btn');
57
+ const board = document.getElementById('garden-board');
58
+ if (!linkBtn || linkBtn._wired) return;
59
+ linkBtn._wired = true;
60
+
61
+ linkBtn.addEventListener('click', () => {
62
+ window._linkMode = !window._linkMode;
63
+ linkBtn.classList.toggle('link-mode-active', window._linkMode);
64
+ if (board) board.classList.toggle('link-mode', window._linkMode);
65
+ if (window._linkSource != null && board) {
66
+ const srcEl = board.querySelector(`.garden-sprite[data-idx="${window._linkSource}"]`);
67
+ if (srcEl) srcEl.classList.remove('link-source');
68
+ }
69
+ window._linkSource = null;
70
+ });
71
+ }
72
+
73
  function attachBoard() {
74
  const board = document.getElementById('garden-board');
75
  if (!board || board._wired) return;
 
125
  window._gardenMove = { idx: drag.idx, x: x, y: y };
126
  const btn = document.getElementById('garden-move-btn');
127
  if (btn) btn.click();
128
+ } else if (window._linkMode) {
129
+ if (window._linkSource == null) {
130
+ window._linkSource = drag.idx;
131
+ drag.el.classList.add('link-source');
132
+ } else if (window._linkSource === drag.idx) {
133
+ drag.el.classList.remove('link-source');
134
+ window._linkSource = null;
135
+ } else {
136
+ const srcEl = board.querySelector(`.garden-sprite[data-idx="${window._linkSource}"]`);
137
+ if (srcEl) srcEl.classList.remove('link-source');
138
+ window._gardenLink = { idx1: window._linkSource, idx2: drag.idx };
139
+ window._linkSource = null;
140
+ const btn = document.getElementById('garden-link-btn');
141
+ if (btn) btn.click();
142
+ }
143
  } else {
144
  window._gardenSelect = { idx: drag.idx };
145
  const btn = document.getElementById('garden-select-btn');
 
151
  board.addEventListener('pointercancel', () => { drag = null; });
152
  }
153
 
154
+ function attachAll() {
155
+ attachBoard();
156
+ attachLinkModeBtn();
157
+ }
158
+
159
+ const observer = new MutationObserver(attachAll);
160
  observer.observe(document.body, { childList: true, subtree: true });
161
+ document.addEventListener('DOMContentLoaded', attachAll);
162
+ attachAll();
163
  })();
164
  </script>
165
  """
 
331
  garden = load_garden(user_id)
332
  today = datetime.date.today()
333
  tiles = []
334
+ positions = {}
335
  for idx, p in enumerate(garden):
336
  sprite_path = Path(pixel_art.get_sprite_path(p["genus"])).resolve()
337
  sprite_url = f"/gradio_api/file={sprite_path.as_posix()}"
 
349
  caption += " 💧"
350
 
351
  pos = p.get("position") or _default_position(idx)
352
+ positions[p["id"]] = pos
353
  tiles.append(
354
  f'<div class="garden-sprite" data-idx="{idx}" style="left:{pos["x"]}%; top:{pos["y"]}%;">'
355
  f'<img src="{sprite_url}" draggable="false" alt="{html.escape(caption)}">'
356
  f'<div class="garden-caption">{html.escape(caption)}</div>'
357
  f'</div>'
358
  )
359
+
360
+ # Draw a line between each pair of hand-linked ("neighbor") plants.
361
+ links = []
362
+ drawn = set()
363
+ for p in garden:
364
+ for neighbor_id in p.get("neighbors", []):
365
+ pair = frozenset((p["id"], neighbor_id))
366
+ if pair in drawn or neighbor_id not in positions:
367
+ continue
368
+ drawn.add(pair)
369
+ a, b = positions[p["id"]], positions[neighbor_id]
370
+ links.append(f'<line x1="{a["x"]}" y1="{a["y"]}" x2="{b["x"]}" y2="{b["y"]}" />')
371
+
372
+ links_svg = (
373
+ f'<svg class="garden-links" viewBox="0 0 100 100" preserveAspectRatio="none">{"".join(links)}</svg>'
374
+ if links else ""
375
+ )
376
+ return f'<div class="garden-board-inner">{links_svg}{"".join(tiles)}</div>'
377
 
378
  def _visible_plants(user_id: str) -> list[dict]:
379
  """All garden plants, in the same order as the gallery."""
 
557
  with gr.Row():
558
 
559
  with gr.Column(scale=3):
560
+ with gr.Row():
561
+ gr.Markdown("_Drag a plant to place it anywhere in your garden, or click it to see details._")
562
+ link_mode_btn = gr.Button("🔗 Relier des plantes", elem_id="link-mode-btn", size="sm", scale=0)
563
 
564
  # Garden board — freely draggable pixel-art sprites
565
  garden_board = gr.HTML(value="", elem_id="garden-board")
 
572
  board_move_x = gr.Number(value=0, visible=False)
573
  board_move_y = gr.Number(value=0, visible=False)
574
  board_move_btn = gr.Button("sync", elem_id="garden-move-btn", elem_classes=["board-sync"])
575
+ board_link_idx1 = gr.Number(value=-1, visible=False)
576
+ board_link_idx2 = gr.Number(value=-1, visible=False)
577
+ board_link_btn = gr.Button("sync", elem_id="garden-link-btn", elem_classes=["board-sync"])
578
 
579
  # Detail panel — appears on click
580
  plant_detail = gr.Markdown("_Click a plant photo to see its details._", elem_id="plant-detail")
 
668
  js="(idx, x, y, user_id) => { const d = window._gardenMove || {idx: -1, x: 0, y: 0}; return [d.idx, d.x, d.y, user_id]; }",
669
  )
670
 
671
+ def toggle_plant_link(idx1, idx2, user_id):
672
+ """Toggle a 'neighbor' link between two plants on the board."""
673
+ idx1, idx2 = int(idx1), int(idx2)
674
+ garden = load_garden(user_id)
675
+ if 0 <= idx1 < len(garden) and 0 <= idx2 < len(garden) and idx1 != idx2:
676
+ id1, id2 = garden[idx1]["id"], garden[idx2]["id"]
677
+ neighbors1 = garden[idx1].setdefault("neighbors", [])
678
+ neighbors2 = garden[idx2].setdefault("neighbors", [])
679
+ if id2 in neighbors1:
680
+ neighbors1.remove(id2)
681
+ neighbors2.remove(id1)
682
+ else:
683
+ neighbors1.append(id2)
684
+ neighbors2.append(id1)
685
+ save_garden(garden, user_id)
686
+ return get_garden_board_html(user_id)
687
+
688
+ board_link_btn.click(
689
+ fn=toggle_plant_link,
690
+ inputs=[board_link_idx1, board_link_idx2, user_id_state],
691
+ outputs=[garden_board],
692
+ js="(idx1, idx2, user_id) => { const d = window._gardenLink || {idx1: -1, idx2: -1}; return [d.idx1, d.idx2, user_id]; }",
693
+ )
694
+
695
  def _mark_watered_by_idx(idx, user_id):
696
  if idx is None:
697
  return "⚠️ Select a plant first.", get_garden_board_html(user_id)
 
717
  return "⚠️ Plant not found.", get_garden_board_html(user_id)
718
  target_id = visible[idx]["id"]
719
  garden = [p for p in load_garden(user_id) if p.get("id") != target_id]
720
+ for p in garden:
721
+ if target_id in p.get("neighbors", []):
722
+ p["neighbors"].remove(target_id)
723
  save_garden(garden, user_id)
724
  return "🗑️ Plant removed.", get_garden_board_html(user_id)
725
 
 
746
  return "Select a plant first."
747
  plant = plants[idx]
748
  info = get_plant_info(plant["genus"])
749
+ by_id = {p["id"]: p for p in plants}
750
+ neighbors = [
751
+ {"name": n.get("nickname") or n["genus"], "genus": n["genus"]}
752
+ for nid in plant.get("neighbors", [])
753
+ if (n := by_id.get(nid)) is not None
754
+ ]
755
  return advisor.ask_about_plant(
756
  question, info,
757
  plant_name=plant.get("nickname"),
758
  genus=plant["genus"],
759
  last_watered=plant.get("last_watered"),
760
+ neighbors=neighbors,
761
  )
762
 
763
  advisor_ask_btn.click(
modules/advisor.py CHANGED
@@ -32,8 +32,13 @@ def _build_system_prompt(
32
  plant_name: str | None = None,
33
  genus: str | None = None,
34
  last_watered: str | None = None,
 
35
  ) -> str:
36
  name = plant_name or genus or "this plant"
 
 
 
 
37
  return (
38
  "You are an expert gardening assistant with deep knowledge of houseplant "
39
  "and garden plant care. Be practical, encouraging, and specific.\n\n"
@@ -43,7 +48,8 @@ def _build_system_prompt(
43
  f"- Soil: {plant_info.get('soil')}\n"
44
  f"- Watering frequency: every {plant_info.get('watering_frequency_days')} days\n"
45
  f"- Fertilization: {plant_info.get('fertilization_type')}\n"
46
- f"- Watering status: {_watering_status(last_watered)}\n\n"
 
47
  "Always factor the watering status into your answer: if it is overdue "
48
  "compared to the recommended frequency, say so and recommend watering; "
49
  "if it was watered recently, take that into account (don't tell the "
@@ -51,7 +57,10 @@ def _build_system_prompt(
51
  "possible cause if the question describes a problem.\n\n"
52
  "Use this profile as context, but also draw on your general gardening "
53
  "knowledge for issues it doesn't cover (pests, diseases, yellowing "
54
- "leaves, repotting, etc.). Give a precise, actionable, gardening-advice "
 
 
 
55
  "focused answer and never recommend dangerous or toxic substances. "
56
  "Answer in 2-4 sentences, in the same language as the question."
57
  )
@@ -63,12 +72,13 @@ def ask_about_plant(
63
  plant_name: str | None = None,
64
  genus: str | None = None,
65
  last_watered: str | None = None,
 
66
  ) -> str:
67
  """Ask the advisor a question about a specific plant, grounded in its care data."""
68
  try:
69
  completion = _get_client().chat_completion(
70
  messages=[
71
- {"role": "system", "content": _build_system_prompt(plant_info, plant_name, genus, last_watered)},
72
  {"role": "user", "content": question},
73
  ],
74
  max_tokens=300,
 
32
  plant_name: str | None = None,
33
  genus: str | None = None,
34
  last_watered: str | None = None,
35
+ neighbors: list[dict] | None = None,
36
  ) -> str:
37
  name = plant_name or genus or "this plant"
38
+ neighbor_line = ""
39
+ if neighbors:
40
+ names = ", ".join(f"{n['name']} ({n['genus']})" for n in neighbors)
41
+ neighbor_line = f"- Plants growing right next to it in the garden: {names}\n"
42
  return (
43
  "You are an expert gardening assistant with deep knowledge of houseplant "
44
  "and garden plant care. Be practical, encouraging, and specific.\n\n"
 
48
  f"- Soil: {plant_info.get('soil')}\n"
49
  f"- Watering frequency: every {plant_info.get('watering_frequency_days')} days\n"
50
  f"- Fertilization: {plant_info.get('fertilization_type')}\n"
51
+ f"- Watering status: {_watering_status(last_watered)}\n"
52
+ f"{neighbor_line}\n"
53
  "Always factor the watering status into your answer: if it is overdue "
54
  "compared to the recommended frequency, say so and recommend watering; "
55
  "if it was watered recently, take that into account (don't tell the "
 
57
  "possible cause if the question describes a problem.\n\n"
58
  "Use this profile as context, but also draw on your general gardening "
59
  "knowledge for issues it doesn't cover (pests, diseases, yellowing "
60
+ "leaves, repotting, etc.). If nearby plants are listed, factor in "
61
+ "companion-planting effects (competition for light, water or nutrients, "
62
+ "shared pests/diseases, or beneficial pairings) when relevant to the "
63
+ "question. Give a precise, actionable, gardening-advice "
64
  "focused answer and never recommend dangerous or toxic substances. "
65
  "Answer in 2-4 sentences, in the same language as the question."
66
  )
 
72
  plant_name: str | None = None,
73
  genus: str | None = None,
74
  last_watered: str | None = None,
75
+ neighbors: list[dict] | None = None,
76
  ) -> str:
77
  """Ask the advisor a question about a specific plant, grounded in its care data."""
78
  try:
79
  completion = _get_client().chat_completion(
80
  messages=[
81
+ {"role": "system", "content": _build_system_prompt(plant_info, plant_name, genus, last_watered, neighbors)},
82
  {"role": "user", "content": question},
83
  ],
84
  max_tokens=300,
static/style.css CHANGED
@@ -185,6 +185,41 @@ footer {
185
  opacity: 0.85;
186
  }
187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  .garden-sprite img {
189
  width: 88px;
190
  height: 88px;
 
185
  opacity: 0.85;
186
  }
187
 
188
+ #garden-board.link-mode .garden-sprite {
189
+ cursor: crosshair;
190
+ }
191
+
192
+ .garden-sprite.link-source img {
193
+ outline: 3px solid #43a047;
194
+ outline-offset: 2px;
195
+ border-radius: 8px;
196
+ }
197
+
198
+ /* SVG overlay drawing lines between hand-linked ("neighbor") plants */
199
+ .garden-links {
200
+ position: absolute;
201
+ inset: 0;
202
+ width: 100%;
203
+ height: 100%;
204
+ pointer-events: none;
205
+ z-index: 0;
206
+ }
207
+
208
+ .garden-links line {
209
+ stroke: #8d6e63;
210
+ stroke-width: 2;
211
+ stroke-dasharray: 5 4;
212
+ stroke-linecap: round;
213
+ vector-effect: non-scaling-stroke;
214
+ }
215
+
216
+ /* "Relier des plantes" toggle button */
217
+ #link-mode-btn.link-mode-active {
218
+ background: linear-gradient(135deg, #66bb6a 0%, #43a047 100%) !important;
219
+ color: #ffffff !important;
220
+ border-color: transparent !important;
221
+ }
222
+
223
  .garden-sprite img {
224
  width: 88px;
225
  height: 88px;