mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
feat: add collapsible analytics, click-to-pin chart tooltips, and extended virtualization
This commit is contained in:
parent
3877890889
commit
20b9e8037c
10 changed files with 1036 additions and 202 deletions
|
|
@ -140,6 +140,90 @@ def warm_validation_name_cache() -> None:
|
|||
pass
|
||||
|
||||
|
||||
def _merge_hx_trigger(response: Any, payload: dict[str, Any]) -> None:
|
||||
if not payload or response is None:
|
||||
return
|
||||
try:
|
||||
existing = response.headers.get("HX-Trigger") if hasattr(response, "headers") else None
|
||||
except Exception:
|
||||
existing = None
|
||||
try:
|
||||
if existing:
|
||||
try:
|
||||
data = json.loads(existing)
|
||||
except Exception:
|
||||
data = {}
|
||||
if isinstance(data, dict):
|
||||
data.update(payload)
|
||||
response.headers["HX-Trigger"] = json.dumps(data)
|
||||
return
|
||||
response.headers["HX-Trigger"] = json.dumps(payload)
|
||||
except Exception:
|
||||
try:
|
||||
response.headers["HX-Trigger"] = json.dumps(payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _step5_summary_placeholder_html(token: int, *, message: str | None = None) -> str:
|
||||
text = message or "Deck summary will appear after the build completes."
|
||||
return (
|
||||
f'<div id="deck-summary" data-summary '
|
||||
f'hx-get="/build/step5/summary?token={token}" '
|
||||
'hx-trigger="load, step5:refresh from:body" hx-swap="outerHTML">'
|
||||
f'<div class="muted" style="margin-top:1rem;">{_esc(text)}</div>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
def _must_have_state(sess: dict) -> tuple[dict[str, Any], list[str], list[str]]:
|
||||
includes = list(sess.get("include_cards") or [])
|
||||
excludes = list(sess.get("exclude_cards") or [])
|
||||
state = {
|
||||
"includes": includes,
|
||||
"excludes": excludes,
|
||||
"enforcement_mode": (sess.get("enforcement_mode") or "warn"),
|
||||
"allow_illegal": bool(sess.get("allow_illegal")),
|
||||
"fuzzy_matching": bool(sess.get("fuzzy_matching", True)),
|
||||
}
|
||||
return state, includes, excludes
|
||||
|
||||
|
||||
def _render_include_exclude_summary(
|
||||
request: Request,
|
||||
sess: dict,
|
||||
sid: str,
|
||||
*,
|
||||
state: dict[str, Any] | None = None,
|
||||
includes: list[str] | None = None,
|
||||
excludes: list[str] | None = None,
|
||||
) -> HTMLResponse:
|
||||
ctx = step5_base_ctx(request, sess, include_name=False, include_locks=False)
|
||||
if state is None or includes is None or excludes is None:
|
||||
state, includes, excludes = _must_have_state(sess)
|
||||
ctx["must_have_state"] = state
|
||||
ctx["summary"] = sess.get("step5_summary") if sess.get("step5_summary_ready") else None
|
||||
ctx["include_cards"] = includes
|
||||
ctx["exclude_cards"] = excludes
|
||||
response = templates.TemplateResponse("partials/include_exclude_summary.html", ctx)
|
||||
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return response
|
||||
|
||||
|
||||
def _current_builder_summary(sess: dict) -> Any | None:
|
||||
try:
|
||||
ctx = sess.get("build_ctx") or {}
|
||||
builder = ctx.get("builder") if isinstance(ctx, dict) else None
|
||||
if builder is None:
|
||||
return None
|
||||
summary_fn = getattr(builder, "build_deck_summary", None)
|
||||
if callable(summary_fn):
|
||||
return summary_fn()
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
_COLOR_NAME_MAP = {
|
||||
"W": "White",
|
||||
"U": "Blue",
|
||||
|
|
@ -772,19 +856,7 @@ async def toggle_must_haves(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
must_state = {
|
||||
"includes": includes,
|
||||
"excludes": excludes,
|
||||
"enforcement_mode": sess.get("enforcement_mode") or "warn",
|
||||
"allow_illegal": bool(sess.get("allow_illegal")),
|
||||
"fuzzy_matching": bool(sess.get("fuzzy_matching", True)),
|
||||
}
|
||||
|
||||
ctx = step5_base_ctx(request, sess, include_name=False, include_locks=False)
|
||||
ctx["must_have_state"] = must_state
|
||||
ctx["summary"] = None
|
||||
response = templates.TemplateResponse("partials/include_exclude_summary.html", ctx)
|
||||
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
response = _render_include_exclude_summary(request, sess, sid)
|
||||
|
||||
try:
|
||||
log_include_exclude_toggle(
|
||||
|
|
@ -806,7 +878,7 @@ async def toggle_must_haves(
|
|||
"exclude_count": len(excludes),
|
||||
}
|
||||
try:
|
||||
response.headers["HX-Trigger"] = json.dumps({"must-haves:toggle": trigger_payload})
|
||||
_merge_hx_trigger(response, {"must-haves:toggle": trigger_payload})
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
|
|
@ -2377,6 +2449,7 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo
|
|||
ctx_resp = step5_error_ctx(request, sess, f"Failed to rewind: {e}")
|
||||
resp = templates.TemplateResponse("build/_step5.html", ctx_resp)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx_resp.get("summary_token", 0)}})
|
||||
return resp
|
||||
|
||||
|
||||
|
|
@ -3016,6 +3089,7 @@ async def build_step5_get(request: Request) -> HTMLResponse:
|
|||
base = step5_empty_ctx(request, sess)
|
||||
resp = templates.TemplateResponse("build/_step5.html", base)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}})
|
||||
return resp
|
||||
|
||||
@router.post("/step5/continue", response_class=HTMLResponse)
|
||||
|
|
@ -3086,6 +3160,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
|
|||
err_ctx = step5_error_ctx(request, sess, f"Failed to continue: {e}")
|
||||
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||
return resp
|
||||
stage_label = res.get("label")
|
||||
# If we just applied Multi-Copy, stamp the applied key so we don't rebuild again
|
||||
|
|
@ -3100,6 +3175,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
|
|||
ctx2 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
|
||||
resp = templates.TemplateResponse("build/_step5.html", ctx2)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}})
|
||||
return resp
|
||||
|
||||
@router.post("/step5/rerun", response_class=HTMLResponse)
|
||||
|
|
@ -3138,6 +3214,7 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
|
|||
err_ctx = step5_error_ctx(request, sess, f"Failed to rerun stage: {e}")
|
||||
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||
return resp
|
||||
sess["last_step"] = 5
|
||||
# Build locked cards list with ownership and in-deck presence
|
||||
|
|
@ -3164,6 +3241,7 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
|
|||
ctx3["locked_cards"] = locked_cards
|
||||
resp = templates.TemplateResponse("build/_step5.html", ctx3)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx3.get("summary_token", 0)}})
|
||||
return resp
|
||||
|
||||
|
||||
|
|
@ -3205,6 +3283,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
|||
ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
|
||||
resp = templates.TemplateResponse("build/_step5.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx.get("summary_token", 0)}})
|
||||
return resp
|
||||
except Exception as e:
|
||||
# Surface a friendly error on the step 5 screen with normalized context
|
||||
|
|
@ -3218,6 +3297,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
|||
err_ctx["commander"] = commander
|
||||
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||
return resp
|
||||
|
||||
@router.get("/step5/start", response_class=HTMLResponse)
|
||||
|
|
@ -3283,8 +3363,61 @@ async def build_step5_reset_stage(request: Request) -> HTMLResponse:
|
|||
})
|
||||
resp = templates.TemplateResponse("build/_step5.html", base)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}})
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/step5/summary", response_class=HTMLResponse)
|
||||
async def build_step5_summary(request: Request, token: int = Query(0)) -> HTMLResponse:
|
||||
sid = request.cookies.get("sid") or request.headers.get("X-Session-ID")
|
||||
if not sid:
|
||||
sid = new_sid()
|
||||
sess = get_session(sid)
|
||||
|
||||
try:
|
||||
session_token = int(sess.get("step5_summary_token", 0))
|
||||
except Exception:
|
||||
session_token = 0
|
||||
try:
|
||||
requested_token = int(token)
|
||||
except Exception:
|
||||
requested_token = 0
|
||||
ready = bool(sess.get("step5_summary_ready"))
|
||||
summary_data = sess.get("step5_summary") if ready else None
|
||||
if summary_data is None and ready:
|
||||
summary_data = _current_builder_summary(sess)
|
||||
if summary_data is not None:
|
||||
try:
|
||||
sess["step5_summary"] = summary_data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
synergies: list[str] = []
|
||||
try:
|
||||
raw_synergies = sess.get("step5_synergies")
|
||||
if isinstance(raw_synergies, (list, tuple, set)):
|
||||
synergies = [str(item) for item in raw_synergies if str(item).strip()]
|
||||
except Exception:
|
||||
synergies = []
|
||||
|
||||
active_token = session_token if session_token >= requested_token else requested_token
|
||||
|
||||
if not ready or summary_data is None:
|
||||
message = "Deck summary will appear after the build completes." if not ready else "Deck summary is not available yet. Try rerunning the current stage."
|
||||
placeholder = _step5_summary_placeholder_html(active_token, message=message)
|
||||
response = HTMLResponse(placeholder)
|
||||
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return response
|
||||
|
||||
ctx = step5_base_ctx(request, sess)
|
||||
ctx["summary"] = summary_data
|
||||
ctx["synergies"] = synergies
|
||||
ctx["summary_ready"] = True
|
||||
ctx["summary_token"] = active_token
|
||||
response = templates.TemplateResponse("partials/deck_summary.html", ctx)
|
||||
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return response
|
||||
|
||||
# --- Phase 8: Lock/Replace/Compare/Permalink minimal API ---
|
||||
|
||||
@router.post("/lock")
|
||||
|
|
@ -4376,6 +4509,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse:
|
|||
err_ctx = step5_error_ctx(request, sess, "No active build context to enforce.")
|
||||
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||
return resp
|
||||
# Ensure we have a CSV base stem for consistent re-exports
|
||||
base_stem = None
|
||||
|
|
@ -4443,6 +4577,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse:
|
|||
err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}")
|
||||
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||
return resp
|
||||
# Reload compliance JSON and summary
|
||||
compliance = None
|
||||
|
|
@ -4490,6 +4625,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse:
|
|||
page_ctx = step5_ctx_from_result(request, sess, res, status_text="Build complete", show_skipped=True)
|
||||
resp = templates.TemplateResponse(request, "build/_step5.html", page_ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": page_ctx.get("summary_token", 0)}})
|
||||
return resp
|
||||
|
||||
|
||||
|
|
@ -4519,9 +4655,14 @@ async def build_enforcement_fullpage(request: Request) -> HTMLResponse:
|
|||
comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
ctx2 = {"request": request, "compliance": comp}
|
||||
try:
|
||||
summary_token = int(sess.get("step5_summary_token", 0))
|
||||
except Exception:
|
||||
summary_token = 0
|
||||
ctx2 = {"request": request, "compliance": comp, "summary_token": summary_token}
|
||||
resp = templates.TemplateResponse(request, "build/enforcement.html", ctx2)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}})
|
||||
return resp
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -88,6 +88,19 @@ def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, i
|
|||
ctx["name"] = sess.get("custom_export_base")
|
||||
if include_locks:
|
||||
ctx["locks"] = list(sess.get("locks", []))
|
||||
try:
|
||||
ctx["summary_token"] = int(sess.get("step5_summary_token", 0))
|
||||
except Exception:
|
||||
ctx["summary_token"] = 0
|
||||
ctx["summary_ready"] = bool(sess.get("step5_summary_ready"))
|
||||
try:
|
||||
raw_synergies = sess.get("step5_synergies")
|
||||
if isinstance(raw_synergies, (list, tuple, set)):
|
||||
ctx["synergies"] = [str(s) for s in raw_synergies if str(s).strip()]
|
||||
else:
|
||||
ctx["synergies"] = []
|
||||
except Exception:
|
||||
ctx["synergies"] = []
|
||||
ctx["must_have_state"] = {
|
||||
"includes": include_cards,
|
||||
"excludes": exclude_cards,
|
||||
|
|
@ -406,7 +419,7 @@ def step5_ctx_from_result(
|
|||
"csv_path": res.get("csv_path") if done else None,
|
||||
"txt_path": res.get("txt_path") if done else None,
|
||||
"summary": res.get("summary") if done else None,
|
||||
"compliance": res.get("compliance") if done else None,
|
||||
"compliance": res.get("compliance") if done else None,
|
||||
"show_skipped": bool(show_skipped),
|
||||
"total_cards": res.get("total_cards"),
|
||||
"added_total": res.get("added_total"),
|
||||
|
|
@ -414,7 +427,7 @@ def step5_ctx_from_result(
|
|||
"clamped_overflow": res.get("clamped_overflow"),
|
||||
"mc_summary": res.get("mc_summary"),
|
||||
"skipped": bool(res.get("skipped")),
|
||||
"gated": bool(res.get("gated")),
|
||||
"gated": bool(res.get("gated")),
|
||||
}
|
||||
if extras:
|
||||
ctx.update(extras)
|
||||
|
|
@ -428,6 +441,57 @@ def step5_ctx_from_result(
|
|||
ctx.update(hover_meta)
|
||||
if "commander_display_name" not in ctx or not ctx.get("commander_display_name"):
|
||||
ctx["commander_display_name"] = ctx.get("commander")
|
||||
|
||||
try:
|
||||
token_val = int(sess.get("step5_summary_token", 0))
|
||||
except Exception:
|
||||
token_val = 0
|
||||
summary_value = ctx.get("summary")
|
||||
synergies_list: list[str] = []
|
||||
if summary_value is not None:
|
||||
try:
|
||||
sess["step5_summary"] = summary_value
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(summary_value, dict):
|
||||
raw_synergies = summary_value.get("synergies")
|
||||
if isinstance(raw_synergies, (list, tuple, set)):
|
||||
synergies_list = [str(item) for item in raw_synergies if str(item).strip()]
|
||||
else:
|
||||
meta = summary_value.get("meta") if isinstance(summary_value.get("meta"), dict) else {}
|
||||
if isinstance(meta, dict):
|
||||
raw_synergies = meta.get("synergies") or meta.get("commander_synergies")
|
||||
if isinstance(raw_synergies, (list, tuple, set)):
|
||||
synergies_list = [str(item) for item in raw_synergies if str(item).strip()]
|
||||
token_val += 1
|
||||
sess["step5_summary_token"] = token_val
|
||||
sess["step5_summary_ready"] = True
|
||||
if synergies_list:
|
||||
sess["step5_synergies"] = synergies_list
|
||||
else:
|
||||
try:
|
||||
if "step5_synergies" in sess:
|
||||
del sess["step5_synergies"]
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
token_val += 1
|
||||
sess["step5_summary_token"] = token_val
|
||||
sess["step5_summary_ready"] = False
|
||||
try:
|
||||
if "step5_summary" in sess:
|
||||
del sess["step5_summary"]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if "step5_synergies" in sess:
|
||||
del sess["step5_synergies"]
|
||||
except Exception:
|
||||
pass
|
||||
synergies_list = []
|
||||
ctx["summary_token"] = token_val
|
||||
ctx["summary_ready"] = bool(sess.get("step5_summary_ready"))
|
||||
ctx["synergies"] = synergies_list
|
||||
return ctx
|
||||
|
||||
|
||||
|
|
@ -463,6 +527,25 @@ def step5_error_ctx(
|
|||
"added_total": 0,
|
||||
"skipped": False,
|
||||
}
|
||||
try:
|
||||
token_val = int(sess.get("step5_summary_token", 0)) + 1
|
||||
except Exception:
|
||||
token_val = 1
|
||||
sess["step5_summary_token"] = token_val
|
||||
sess["step5_summary_ready"] = False
|
||||
try:
|
||||
if "step5_summary" in sess:
|
||||
del sess["step5_summary"]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if "step5_synergies" in sess:
|
||||
del sess["step5_synergies"]
|
||||
except Exception:
|
||||
pass
|
||||
ctx["summary_token"] = token_val
|
||||
ctx["summary_ready"] = False
|
||||
ctx["synergies"] = []
|
||||
if extras:
|
||||
ctx.update(extras)
|
||||
return ctx
|
||||
|
|
@ -494,6 +577,25 @@ def step5_empty_ctx(
|
|||
"show_skipped": False,
|
||||
"skipped": False,
|
||||
}
|
||||
try:
|
||||
token_val = int(sess.get("step5_summary_token", 0)) + 1
|
||||
except Exception:
|
||||
token_val = 1
|
||||
sess["step5_summary_token"] = token_val
|
||||
sess["step5_summary_ready"] = False
|
||||
try:
|
||||
if "step5_summary" in sess:
|
||||
del sess["step5_summary"]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if "step5_synergies" in sess:
|
||||
del sess["step5_synergies"]
|
||||
except Exception:
|
||||
pass
|
||||
ctx["summary_token"] = token_val
|
||||
ctx["summary_ready"] = False
|
||||
ctx["synergies"] = []
|
||||
if extras:
|
||||
ctx.update(extras)
|
||||
return ctx
|
||||
|
|
|
|||
|
|
@ -798,9 +798,8 @@
|
|||
// --- Lightweight virtualization (feature-flagged via data-virtualize) ---
|
||||
function initVirtualization(root){
|
||||
try{
|
||||
var body = document.body || document.documentElement;
|
||||
var DIAG = !!(body && body.getAttribute('data-diag') === '1');
|
||||
// Global diagnostics aggregator
|
||||
var body = document.body || document.documentElement;
|
||||
var DIAG = !!(body && body.getAttribute('data-diag') === '1');
|
||||
var GLOBAL = (function(){
|
||||
if (!DIAG) return null;
|
||||
if (window.__virtGlobal) return window.__virtGlobal;
|
||||
|
|
@ -821,7 +820,6 @@
|
|||
el.style.zIndex = '50';
|
||||
el.style.boxShadow = '0 4px 12px rgba(0,0,0,.35)';
|
||||
el.style.cursor = 'default';
|
||||
// Hidden by default; toggle with 'v'
|
||||
el.style.display = 'none';
|
||||
document.body.appendChild(el);
|
||||
store.summaryEl = el;
|
||||
|
|
@ -837,7 +835,7 @@
|
|||
visible += (g[i].end||0) - (g[i].start||0);
|
||||
lastMs = Math.max(lastMs, g[i].lastMs||0);
|
||||
}
|
||||
el.textContent = 'virt sum: grids '+g.length+' • visible '+visible+'/'+total+' • last '+lastMs.toFixed ? lastMs.toFixed(1) : String(lastMs)+'ms';
|
||||
el.textContent = 'virt sum: grids '+g.length+' • visible '+visible+'/'+total+' • last '+(lastMs.toFixed ? lastMs.toFixed(1) : String(lastMs))+'ms';
|
||||
}
|
||||
function register(gridId, ref){
|
||||
store.grids.push({ id: gridId, ref: ref });
|
||||
|
|
@ -852,48 +850,66 @@
|
|||
}
|
||||
update();
|
||||
},
|
||||
toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); }
|
||||
toggle: function(){
|
||||
var el = ensure();
|
||||
el.style.display = (el.style.display === 'none' ? '' : 'none');
|
||||
}
|
||||
};
|
||||
}
|
||||
window.__virtGlobal = { register: register, toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); } };
|
||||
window.__virtGlobal = {
|
||||
register: register,
|
||||
toggle: function(){
|
||||
var el = ensure();
|
||||
el.style.display = (el.style.display === 'none' ? '' : 'none');
|
||||
}
|
||||
};
|
||||
return window.__virtGlobal;
|
||||
})();
|
||||
// Support card grids and other scroll containers (e.g., #owned-box)
|
||||
var grids = (root || document).querySelectorAll('.card-grid[data-virtualize="1"], #owned-box[data-virtualize="1"]');
|
||||
|
||||
var scope = root || document;
|
||||
if (!scope || !scope.querySelectorAll) return;
|
||||
var grids = scope.querySelectorAll('[data-virtualize]');
|
||||
if (!grids.length) return;
|
||||
|
||||
grids.forEach(function(grid){
|
||||
if (grid.__virtBound) return;
|
||||
grid.__virtBound = true;
|
||||
// Basic windowing: assumes roughly similar tile heights; uses sentinel measurements.
|
||||
if (!grid || grid.__virtBound) return;
|
||||
var attrVal = (grid.getAttribute('data-virtualize') || '').trim();
|
||||
if (!attrVal || /^0|false$/i.test(attrVal)) return;
|
||||
|
||||
var container = grid;
|
||||
container.style.position = container.style.position || 'relative';
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.className = 'virt-wrapper';
|
||||
// Ensure wrapper itself is a grid to preserve multi-column layout inside
|
||||
// when the container (e.g., .card-grid) is virtualized.
|
||||
wrapper.style.display = 'grid';
|
||||
// Move children into a fragment store (for owned, children live under UL)
|
||||
|
||||
var mode = attrVal.toLowerCase();
|
||||
var minItemsAttr = parseInt(grid.getAttribute('data-virtualize-min') || (grid.dataset ? grid.dataset.virtualizeMin : ''), 10);
|
||||
var rowAttr = parseInt(grid.getAttribute('data-virtualize-row') || (grid.dataset ? grid.dataset.virtualizeRow : ''), 10);
|
||||
var colAttr = parseInt(grid.getAttribute('data-virtualize-columns') || (grid.dataset ? grid.dataset.virtualizeColumns : ''), 10);
|
||||
var maxHeightAttr = grid.getAttribute('data-virtualize-max-height') || (grid.dataset ? grid.dataset.virtualizeMaxHeight : '');
|
||||
var overflowAttr = grid.getAttribute('data-virtualize-overflow') || (grid.dataset ? grid.dataset.virtualizeOverflow : '');
|
||||
|
||||
var source = container;
|
||||
// If this is the owned box, use the UL inside as the source list
|
||||
var ownedGrid = container.id === 'owned-box' ? container.querySelector('#owned-grid') : null;
|
||||
if (ownedGrid) { source = ownedGrid; }
|
||||
if (!source || !source.children || !source.children.length) return;
|
||||
|
||||
var all = Array.prototype.slice.call(source.children);
|
||||
// Threshold: skip virtualization for small grids to avoid scroll jitter at end-of-list.
|
||||
// Empirically flicker was reported when reaching the bottom of short grids (e.g., < 80 tiles)
|
||||
// due to dynamic height adjustments (image loads + padding recalcs). Keeping full DOM
|
||||
// is cheaper than the complexity for small sets.
|
||||
var MIN_VIRT_ITEMS = 80;
|
||||
if (all.length < MIN_VIRT_ITEMS){
|
||||
// Mark as processed so we don't attempt again on HTMX swaps.
|
||||
return; // children remain in place; no virtualization applied.
|
||||
}
|
||||
all.forEach(function(node, idx){ try{ node.__virtIndex = idx; }catch(_){ } });
|
||||
var minItems = !isNaN(minItemsAttr) ? Math.max(0, minItemsAttr) : 80;
|
||||
if (all.length < minItems) return;
|
||||
|
||||
grid.__virtBound = true;
|
||||
|
||||
var store = document.createElement('div');
|
||||
store.style.display = 'none';
|
||||
all.forEach(function(n){ store.appendChild(n); });
|
||||
all.forEach(function(node){ store.appendChild(node); });
|
||||
|
||||
var padTop = document.createElement('div');
|
||||
var padBottom = document.createElement('div');
|
||||
padTop.style.height = '0px'; padBottom.style.height = '0px';
|
||||
// For owned, keep the UL but render into it; otherwise append wrapper to container
|
||||
padTop.style.height = '0px';
|
||||
padBottom.style.height = '0px';
|
||||
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.className = 'virt-wrapper';
|
||||
|
||||
if (ownedGrid){
|
||||
ownedGrid.innerHTML = '';
|
||||
ownedGrid.appendChild(padTop);
|
||||
|
|
@ -901,17 +917,34 @@
|
|||
ownedGrid.appendChild(padBottom);
|
||||
ownedGrid.appendChild(store);
|
||||
} else {
|
||||
container.appendChild(padTop);
|
||||
container.appendChild(wrapper);
|
||||
container.appendChild(padBottom);
|
||||
container.appendChild(store);
|
||||
}
|
||||
var rowH = container.id === 'owned-box' ? 160 : 240; // estimate tile height
|
||||
var perRow = 1;
|
||||
// Optional diagnostics overlay
|
||||
|
||||
if (maxHeightAttr){
|
||||
container.style.maxHeight = maxHeightAttr;
|
||||
} else if (!container.style.maxHeight){
|
||||
container.style.maxHeight = '70vh';
|
||||
}
|
||||
if (overflowAttr){
|
||||
container.style.overflow = overflowAttr;
|
||||
} else if (!container.style.overflow){
|
||||
container.style.overflow = 'auto';
|
||||
}
|
||||
|
||||
var baseRow = container.id === 'owned-box' ? 160 : (mode.indexOf('list') > -1 ? 110 : 240);
|
||||
var minRowH = !isNaN(rowAttr) && rowAttr > 0 ? rowAttr : baseRow;
|
||||
var rowH = minRowH;
|
||||
var explicitCols = (!isNaN(colAttr) && colAttr > 0) ? colAttr : null;
|
||||
var perRow = explicitCols || 1;
|
||||
|
||||
var diagBox = null; var lastRenderAt = 0; var lastRenderMs = 0;
|
||||
var renderCount = 0; var measureCount = 0; var swapCount = 0;
|
||||
var gridId = (container.id || container.className || 'grid') + '#' + Math.floor(Math.random()*1e6);
|
||||
var globalReg = DIAG && GLOBAL ? GLOBAL.register(gridId, container) : null;
|
||||
|
||||
function fmt(n){ try{ return (Math.round(n*10)/10).toFixed(1); }catch(_){ return String(n); } }
|
||||
function ensureDiag(){
|
||||
if (!DIAG) return null;
|
||||
|
|
@ -928,8 +961,7 @@
|
|||
diagBox.style.fontSize = '12px';
|
||||
diagBox.style.margin = '0 0 .35rem 0';
|
||||
diagBox.style.color = '#cbd5e1';
|
||||
diagBox.style.display = 'none'; // hidden until toggled
|
||||
// Controls
|
||||
diagBox.style.display = 'none';
|
||||
var controls = document.createElement('div');
|
||||
controls.style.display = 'flex';
|
||||
controls.style.gap = '.35rem';
|
||||
|
|
@ -937,107 +969,204 @@
|
|||
controls.style.marginBottom = '.25rem';
|
||||
var title = document.createElement('div'); title.textContent = 'virt diag'; title.style.fontWeight = '600'; title.style.fontSize = '11px'; title.style.color = '#9ca3af';
|
||||
var btnCopy = document.createElement('button'); btnCopy.type = 'button'; btnCopy.textContent = 'Copy'; btnCopy.className = 'btn small';
|
||||
btnCopy.addEventListener('click', function(){ try{ var payload = {
|
||||
id: gridId, rowH: rowH, perRow: perRow, start: start, end: end, total: total,
|
||||
renderCount: renderCount, measureCount: measureCount, swapCount: swapCount,
|
||||
lastRenderMs: lastRenderMs, lastRenderAt: lastRenderAt
|
||||
}; navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); btnCopy.textContent = 'Copied'; setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200); }catch(_){ }
|
||||
btnCopy.addEventListener('click', function(){
|
||||
try{
|
||||
var payload = {
|
||||
id: gridId,
|
||||
rowH: rowH,
|
||||
perRow: perRow,
|
||||
start: start,
|
||||
end: end,
|
||||
total: total,
|
||||
renderCount: renderCount,
|
||||
measureCount: measureCount,
|
||||
swapCount: swapCount,
|
||||
lastRenderMs: lastRenderMs,
|
||||
lastRenderAt: lastRenderAt,
|
||||
};
|
||||
navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
|
||||
btnCopy.textContent = 'Copied';
|
||||
setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200);
|
||||
}catch(_){ }
|
||||
});
|
||||
var btnHide = document.createElement('button'); btnHide.type = 'button'; btnHide.textContent = 'Hide'; btnHide.className = 'btn small';
|
||||
btnHide.addEventListener('click', function(){ diagBox.style.display = 'none'; });
|
||||
controls.appendChild(title); controls.appendChild(btnCopy); controls.appendChild(btnHide);
|
||||
controls.appendChild(title);
|
||||
controls.appendChild(btnCopy);
|
||||
controls.appendChild(btnHide);
|
||||
diagBox.appendChild(controls);
|
||||
var text = document.createElement('div'); text.className = 'virt-diag-text'; diagBox.appendChild(text);
|
||||
var host = (container.id === 'owned-box') ? container : container.parentElement || container;
|
||||
host.insertBefore(diagBox, host.firstChild);
|
||||
return diagBox;
|
||||
}
|
||||
|
||||
function measure(){
|
||||
try {
|
||||
measureCount++;
|
||||
// create a temp tile to measure if none
|
||||
var probe = store.firstElementChild || all[0];
|
||||
if (probe){
|
||||
var fake = probe.cloneNode(true);
|
||||
fake.style.position = 'absolute'; fake.style.visibility = 'hidden'; fake.style.pointerEvents = 'none';
|
||||
fake.style.position = 'absolute';
|
||||
fake.style.visibility = 'hidden';
|
||||
fake.style.pointerEvents = 'none';
|
||||
(ownedGrid || container).appendChild(fake);
|
||||
var rect = fake.getBoundingClientRect();
|
||||
rowH = Math.max(120, Math.ceil(rect.height) + 16);
|
||||
rowH = Math.max(minRowH, Math.ceil(rect.height) + 16);
|
||||
(ownedGrid || container).removeChild(fake);
|
||||
}
|
||||
// Estimate perRow via computed styles of grid
|
||||
var style = window.getComputedStyle(ownedGrid || container);
|
||||
var cols = style.getPropertyValue('grid-template-columns');
|
||||
// Mirror grid settings onto the wrapper so its children still flow in columns
|
||||
try {
|
||||
var displayMode = style.getPropertyValue('display');
|
||||
if (displayMode && displayMode.trim()){
|
||||
wrapper.style.display = displayMode;
|
||||
} else if (!wrapper.style.display){
|
||||
wrapper.style.display = 'grid';
|
||||
}
|
||||
if (cols && cols.trim()) wrapper.style.gridTemplateColumns = cols;
|
||||
var gap = style.getPropertyValue('gap') || style.getPropertyValue('grid-gap');
|
||||
if (gap && gap.trim()) wrapper.style.gap = gap;
|
||||
// Inherit justify/align if present
|
||||
var ji = style.getPropertyValue('justify-items');
|
||||
if (ji && ji.trim()) wrapper.style.justifyItems = ji;
|
||||
var ai = style.getPropertyValue('align-items');
|
||||
if (ai && ai.trim()) wrapper.style.alignItems = ai;
|
||||
} catch(_) {}
|
||||
perRow = Math.max(1, (cols && cols.split ? cols.split(' ').filter(function(x){return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1);}).length : 1));
|
||||
} catch(_){}
|
||||
} catch(_){ }
|
||||
var derivedCols = (cols && cols.split ? cols.split(' ').filter(function(x){
|
||||
return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1);
|
||||
}).length : 0);
|
||||
if (explicitCols){
|
||||
perRow = explicitCols;
|
||||
} else if (derivedCols){
|
||||
perRow = Math.max(1, derivedCols);
|
||||
} else {
|
||||
perRow = Math.max(1, perRow);
|
||||
}
|
||||
} catch(_){ }
|
||||
}
|
||||
|
||||
measure();
|
||||
var total = all.length;
|
||||
var start = 0, end = 0;
|
||||
|
||||
function render(){
|
||||
var t0 = DIAG ? performance.now() : 0;
|
||||
var scroller = container;
|
||||
var vh = scroller.clientHeight || window.innerHeight;
|
||||
var scrollTop = scroller.scrollTop;
|
||||
// If container isn’t scrollable, use window scroll offset
|
||||
var top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0);
|
||||
var rowsInView = Math.ceil(vh / rowH) + 2; // overscan
|
||||
var rowStart = Math.max(0, Math.floor(top / rowH) - 1);
|
||||
var rowEnd = Math.min(Math.ceil((top / rowH)) + rowsInView, Math.ceil(total / perRow));
|
||||
var newStart = rowStart * perRow;
|
||||
var newEnd = Math.min(total, rowEnd * perRow);
|
||||
if (newStart === start && newEnd === end) return; // no change
|
||||
start = newStart; end = newEnd;
|
||||
// Padding
|
||||
var beforeRows = Math.floor(start / perRow);
|
||||
var afterRows = Math.ceil((total - end) / perRow);
|
||||
var vh, scrollTop, top;
|
||||
|
||||
if (useWindowScroll) {
|
||||
// Window-scroll mode: measure relative to viewport
|
||||
vh = window.innerHeight;
|
||||
var rect = container.getBoundingClientRect();
|
||||
top = Math.max(0, -rect.top);
|
||||
scrollTop = window.pageYOffset || document.documentElement.scrollTop || 0;
|
||||
} else {
|
||||
// Container-scroll mode: measure relative to container
|
||||
vh = scroller.clientHeight || window.innerHeight;
|
||||
scrollTop = scroller.scrollTop;
|
||||
top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0);
|
||||
}
|
||||
|
||||
var rowsInView = Math.ceil(vh / Math.max(1, rowH)) + 2;
|
||||
var rowStart = Math.max(0, Math.floor(top / Math.max(1, rowH)) - 1);
|
||||
var rowEnd = Math.min(Math.ceil(top / Math.max(1, rowH)) + rowsInView, Math.ceil(total / Math.max(1, perRow)));
|
||||
var newStart = rowStart * Math.max(1, perRow);
|
||||
var newEnd = Math.min(total, rowEnd * Math.max(1, perRow));
|
||||
if (newStart === start && newEnd === end) return;
|
||||
start = newStart;
|
||||
end = newEnd;
|
||||
var beforeRows = Math.floor(start / Math.max(1, perRow));
|
||||
var afterRows = Math.ceil((total - end) / Math.max(1, perRow));
|
||||
padTop.style.height = (beforeRows * rowH) + 'px';
|
||||
padBottom.style.height = (afterRows * rowH) + 'px';
|
||||
// Render visible children
|
||||
wrapper.innerHTML = '';
|
||||
for (var i = start; i < end; i++) {
|
||||
for (var i = start; i < end; i++){
|
||||
var node = all[i];
|
||||
if (node) wrapper.appendChild(node);
|
||||
}
|
||||
if (DIAG){
|
||||
var box = ensureDiag();
|
||||
if (box){
|
||||
var dt = performance.now() - t0; lastRenderMs = dt; renderCount++; lastRenderAt = Date.now();
|
||||
var vis = end - start; var rowsTotal = Math.ceil(total / perRow);
|
||||
var dt = performance.now() - t0;
|
||||
lastRenderMs = dt;
|
||||
renderCount++;
|
||||
lastRenderAt = Date.now();
|
||||
var vis = end - start;
|
||||
var rowsTotal = Math.ceil(total / Math.max(1, perRow));
|
||||
var textEl = box.querySelector('.virt-diag-text');
|
||||
var msg = 'range ['+start+'..'+end+') of '+total+' • vis '+vis+' • rows ~'+rowsTotal+' • perRow '+perRow+' • rowH '+rowH+'px • render '+fmt(dt)+'ms • renders '+renderCount+' • measures '+measureCount+' • swaps '+swapCount;
|
||||
textEl.textContent = msg;
|
||||
// Health hint
|
||||
var bad = (dt > 33) || (vis > 300);
|
||||
var warn = (!bad) && ((dt > 16) || (vis > 200));
|
||||
box.style.borderColor = bad ? '#ef4444' : (warn ? '#f59e0b' : 'var(--border)');
|
||||
box.style.boxShadow = bad ? '0 0 0 1px rgba(239,68,68,.35)' : (warn ? '0 0 0 1px rgba(245,158,11,.25)' : 'none');
|
||||
if (globalReg && globalReg.set){ globalReg.set({ total: total, start: start, end: end, lastMs: dt }); }
|
||||
if (globalReg && globalReg.set){
|
||||
globalReg.set({ total: total, start: start, end: end, lastMs: dt });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll(){ render(); }
|
||||
function onResize(){ measure(); render(); }
|
||||
container.addEventListener('scroll', onScroll);
|
||||
|
||||
// Support both container-scroll (default) and window-scroll modes
|
||||
var scrollMode = overflowAttr || container.style.overflow || 'auto';
|
||||
var useWindowScroll = (scrollMode === 'visible' || scrollMode === 'window');
|
||||
|
||||
if (useWindowScroll) {
|
||||
// Window-scroll mode: listen to window scroll events
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
} else {
|
||||
// Container-scroll mode: listen to container scroll events
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
window.addEventListener('resize', onResize);
|
||||
// Initial size; ensure container is scrollable for our logic
|
||||
if (!container.style.maxHeight) container.style.maxHeight = '70vh';
|
||||
container.style.overflow = container.style.overflow || 'auto';
|
||||
|
||||
render();
|
||||
// Re-render after filters resort or HTMX swaps
|
||||
document.addEventListener('htmx:afterSwap', function(ev){ if (container.contains(ev.target)) { swapCount++; all = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children)); total = all.length; measure(); render(); } });
|
||||
// Keyboard toggle for overlays: 'v'
|
||||
|
||||
// Track cleanup for disconnected containers
|
||||
grid.__virtCleanup = function(){
|
||||
try {
|
||||
if (useWindowScroll) {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
} else {
|
||||
container.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
window.removeEventListener('resize', onResize);
|
||||
} catch(_){}
|
||||
};
|
||||
|
||||
document.addEventListener('htmx:afterSwap', function(ev){
|
||||
if (!container.isConnected) return;
|
||||
if (!container.contains(ev.target)) return;
|
||||
swapCount++;
|
||||
var merged = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children));
|
||||
var known = new Map();
|
||||
all.forEach(function(node, idx){
|
||||
var index = (typeof node.__virtIndex === 'number') ? node.__virtIndex : idx;
|
||||
known.set(node, index);
|
||||
});
|
||||
var nextIndex = known.size;
|
||||
merged.forEach(function(node){
|
||||
if (!known.has(node)){
|
||||
node.__virtIndex = nextIndex;
|
||||
known.set(node, nextIndex);
|
||||
nextIndex++;
|
||||
}
|
||||
});
|
||||
merged.sort(function(a, b){
|
||||
var ia = known.get(a);
|
||||
var ib = known.get(b);
|
||||
return (ia - ib);
|
||||
});
|
||||
merged.forEach(function(node, idx){ node.__virtIndex = idx; });
|
||||
all = merged;
|
||||
total = all.length;
|
||||
measure();
|
||||
render();
|
||||
});
|
||||
|
||||
if (DIAG && !window.__virtHotkeyBound){
|
||||
window.__virtHotkeyBound = true;
|
||||
document.addEventListener('keydown', function(e){
|
||||
|
|
@ -1045,9 +1174,11 @@
|
|||
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
|
||||
if (e.key && e.key.toLowerCase() === 'v'){
|
||||
e.preventDefault();
|
||||
// Toggle all virt-diag boxes and the global summary
|
||||
var shown = null;
|
||||
document.querySelectorAll('.virt-diag').forEach(function(b){ if (shown === null) shown = (b.style.display === 'none'); b.style.display = shown ? '' : 'none'; });
|
||||
document.querySelectorAll('.virt-diag').forEach(function(b){
|
||||
if (shown === null) shown = (b.style.display === 'none');
|
||||
b.style.display = shown ? '' : 'none';
|
||||
});
|
||||
if (GLOBAL && GLOBAL.toggle) GLOBAL.toggle();
|
||||
}
|
||||
}catch(_){ }
|
||||
|
|
@ -1198,4 +1329,61 @@
|
|||
});
|
||||
}catch(_){ }
|
||||
});
|
||||
|
||||
// --- Lazy-loading analytics accordions ---
|
||||
function initLazyAccordions(root){
|
||||
try {
|
||||
var scope = root || document;
|
||||
if (!scope || !scope.querySelectorAll) return;
|
||||
|
||||
scope.querySelectorAll('.analytics-accordion[data-lazy-load]').forEach(function(details){
|
||||
if (!details || details.__lazyBound) return;
|
||||
details.__lazyBound = true;
|
||||
|
||||
var loaded = false;
|
||||
|
||||
details.addEventListener('toggle', function(){
|
||||
if (!details.open || loaded) return;
|
||||
loaded = true;
|
||||
|
||||
// Mark as loaded to prevent re-initialization
|
||||
var content = details.querySelector('.analytics-content');
|
||||
if (!content) return;
|
||||
|
||||
// Remove placeholder if present
|
||||
var placeholder = content.querySelector('.analytics-placeholder');
|
||||
if (placeholder) {
|
||||
placeholder.remove();
|
||||
}
|
||||
|
||||
// Content is already rendered in the template, just need to initialize any scripts
|
||||
// Re-run virtualization if needed
|
||||
try {
|
||||
initVirtualization(content);
|
||||
} catch(_){}
|
||||
|
||||
// Re-attach chart interactivity if this is mana overview
|
||||
var type = details.getAttribute('data-analytics-type');
|
||||
if (type === 'mana') {
|
||||
try {
|
||||
// Tooltip and highlight logic is already in the template scripts
|
||||
// Just trigger a synthetic event to re-attach if needed
|
||||
var event = new CustomEvent('analytics:loaded', { detail: { type: 'mana' } });
|
||||
details.dispatchEvent(event);
|
||||
} catch(_){}
|
||||
}
|
||||
|
||||
// Send telemetry
|
||||
telemetry.send('analytics.accordion_expand', {
|
||||
type: type || 'unknown',
|
||||
accordion: details.id || 'unnamed',
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch(_){}
|
||||
}
|
||||
|
||||
// Initialize on load and after HTMX swaps
|
||||
document.addEventListener('DOMContentLoaded', function(){ initLazyAccordions(); });
|
||||
document.addEventListener('htmx:afterSwap', function(e){ initLazyAccordions(e.target); });
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -627,3 +627,54 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
|
|||
@media (min-width: 900px) {
|
||||
#test-hand { --card-w: 280px !important; --card-h: 392px !important; }
|
||||
}
|
||||
|
||||
/* Analytics accordion styling */
|
||||
.analytics-accordion {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.analytics-accordion summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.analytics-accordion summary:hover {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.analytics-accordion summary:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.analytics-accordion[open] summary {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.analytics-accordion .analytics-content {
|
||||
animation: accordion-slide-down 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes accordion-slide-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.analytics-placeholder .skeleton-pulse {
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
{# Flagged tiles by category, in the same card grid style #}
|
||||
{% if flagged_meta and flagged_meta|length > 0 %}
|
||||
<h5 style="margin:.75rem 0 .35rem 0;">Flagged cards</h5>
|
||||
<div class="card-grid">
|
||||
<div class="card-grid"{% if flagged_meta|length >= 12 %} data-virtualize="grid" data-virtualize-min="12" data-virtualize-columns="4"{% endif %}>
|
||||
{% for f in flagged_meta %}
|
||||
{% set sev = (f.severity or 'FAIL')|upper %}
|
||||
<div class="card-tile" data-card-name="{{ f.name }}" data-role="{{ f.role or '' }}" {% if sev == 'FAIL' %}style="border-color: var(--red-main);"{% elif sev == 'WARN' %}style="border-color: var(--orange-main);"{% endif %}>
|
||||
|
|
|
|||
|
|
@ -462,11 +462,16 @@
|
|||
<!-- controls now above -->
|
||||
|
||||
{% if allow_must_haves %}
|
||||
{% include "partials/include_exclude_summary.html" %}
|
||||
{% endif %}
|
||||
{% if status and status.startswith('Build complete') and summary %}
|
||||
{% include "partials/deck_summary.html" %}
|
||||
{% include "partials/include_exclude_summary.html" with oob=False %}
|
||||
{% endif %}
|
||||
<div id="deck-summary" data-summary
|
||||
hx-get="/build/step5/summary?token={{ summary_token }}"
|
||||
hx-trigger="load, step5:refresh from:body"
|
||||
hx-swap="outerHTML">
|
||||
<div class="muted" style="margin-top:1rem;">
|
||||
{% if summary_ready %}Loading deck summary…{% else %}Deck summary will appear after the build completes.{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
<div id="deck-summary" data-summary>
|
||||
<hr style="margin:1.25rem 0; border-color: var(--border);" />
|
||||
<h4>Deck Summary</h4>
|
||||
<section style="margin-top:.5rem;">
|
||||
|
|
@ -55,7 +56,7 @@
|
|||
.dfc-land-chip.extra { border-color:#34d399; color:#a7f3d0; }
|
||||
.dfc-land-chip.counts { border-color:#60a5fa; color:#bfdbfe; }
|
||||
</style>
|
||||
<div class="list-grid">
|
||||
<div class="list-grid"{% if virtualize %} data-virtualize="list" data-virtualize-min="90"{% endif %}>
|
||||
{% for c in clist %}
|
||||
{# Compute overlaps with detected deck synergies when available #}
|
||||
{% set overlaps = [] %}
|
||||
|
|
@ -190,7 +191,13 @@
|
|||
|
||||
<!-- Mana Overview Row: Pips • Sources • Curve -->
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Mana Overview</h5>
|
||||
<details class="analytics-accordion" id="mana-overview-accordion" data-lazy-load data-analytics-type="mana">
|
||||
<summary style="cursor:pointer; user-select:none; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#12161c; font-weight:600;">
|
||||
<span>Mana Overview</span>
|
||||
<span class="muted" style="font-size:12px; font-weight:400; margin-left:.5rem;">(pips • sources • curve)</span>
|
||||
</summary>
|
||||
<div class="analytics-content" style="margin-top:.75rem;">
|
||||
<h5 style="margin:0 0 .5rem 0;">Mana Overview</h5>
|
||||
{% set deck_colors = summary.colors or [] %}
|
||||
<div class="mana-row" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; align-items: stretch;">
|
||||
<!-- Pips Panel -->
|
||||
|
|
@ -203,28 +210,26 @@
|
|||
{% for color in colors %}
|
||||
{% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %}
|
||||
{% set pct = (w * 100) | int %}
|
||||
<div style="text-align:center;">
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%">
|
||||
{% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %}
|
||||
{% set pc = pd['cards'] if 'cards' in pd else None %}
|
||||
{% set c_cards = (pc[color] if pc and (color in pc) else []) %}
|
||||
{% set parts = [] %}
|
||||
{% for c in c_cards %}
|
||||
{% set label = c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '') %}
|
||||
{% if c.dfc %}
|
||||
{% set label = label ~ ' (DFC)' %}
|
||||
{% endif %}
|
||||
{% set _ = parts.append(label) %}
|
||||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
<div style="text-align:center;" class="chart-column">
|
||||
{% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %}
|
||||
{% set pc = pd['cards'] if 'cards' in pd else None %}
|
||||
{% set c_cards = (pc[color] if pc and (color in pc) else []) %}
|
||||
{% set parts = [] %}
|
||||
{% for c in c_cards %}
|
||||
{% set label = c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '') %}
|
||||
{% if c.dfc %}
|
||||
{% set label = label ~ ' (DFC)' %}
|
||||
{% endif %}
|
||||
{% set _ = parts.append(label) %}
|
||||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%" style="cursor:pointer;" data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></rect>
|
||||
{% set h = (pct * 1.0) | int %}
|
||||
{% set bar_h = (h if h>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4"
|
||||
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4" pointer-events="all"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||
</div>
|
||||
|
|
@ -260,22 +265,20 @@
|
|||
{% for color in colors %}
|
||||
{% set val = mg.get(color, 0) %}
|
||||
{% set pct = (val * 100 / denom) | int %}
|
||||
<div style="text-align:center;" data-color="{{ color }}">
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ val }}">
|
||||
{% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %}
|
||||
{% set mgc = mg['cards'] if 'cards' in mg else None %}
|
||||
{% set c_cards = (mgc[color] if mgc and (color in mgc) else []) %}
|
||||
{% set parts = [] %}
|
||||
{% for c in c_cards %}
|
||||
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
||||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
{% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %}
|
||||
{% set mgc = mg['cards'] if 'cards' in mg else None %}
|
||||
{% set c_cards = (mgc[color] if mgc and (color in mgc) else []) %}
|
||||
{% set parts = [] %}
|
||||
{% for c in c_cards %}
|
||||
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
||||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
<div style="text-align:center;" class="chart-column" data-color="{{ color }}">
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ val }}" style="cursor:pointer;" data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></rect>
|
||||
{% set bar_h = (pct if pct>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4"
|
||||
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4" pointer-events="all"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||
</div>
|
||||
|
|
@ -298,21 +301,19 @@
|
|||
{% for label in ['0','1','2','3','4','5','6+'] %}
|
||||
{% set val = mc.get(label, 0) %}
|
||||
{% set pct = (val * 100 / denom) | int %}
|
||||
<div style="text-align:center;">
|
||||
<svg width="28" height="120" aria-label="{{ label }} {{ val }}">
|
||||
{% set cards = (mc.cards[label] if mc.cards and (label in mc.cards) else []) %}
|
||||
{% set parts = [] %}
|
||||
{% for c in cards %}
|
||||
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
||||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
{% set pct_f = (100.0 * (val / denom)) %}
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
{% set cards = (mc.cards[label] if mc.cards and (label in mc.cards) else []) %}
|
||||
{% set parts = [] %}
|
||||
{% for c in cards %}
|
||||
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
||||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
{% set pct_f = (100.0 * (val / denom)) %}
|
||||
<div style="text-align:center;" class="chart-column">
|
||||
<svg width="28" height="120" aria-label="{{ label }} {{ val }}" style="cursor:pointer;" data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></rect>
|
||||
{% set bar_h = (pct if pct>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4"
|
||||
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4" pointer-events="all"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ label }}</div>
|
||||
</div>
|
||||
|
|
@ -324,10 +325,18 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<!-- Test Hand (7 random cards; duplicates allowed only for basic lands) -->
|
||||
<section style="margin-top:1rem;">
|
||||
<details class="analytics-accordion" id="test-hand-accordion" data-lazy-load data-analytics-type="testhand">
|
||||
<summary style="cursor:pointer; user-select:none; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#12161c; font-weight:600;">
|
||||
<span>Test Hand</span>
|
||||
<span class="muted" style="font-size:12px; font-weight:400; margin-left:.5rem;">(draw 7 random cards)</span>
|
||||
</summary>
|
||||
<div class="analytics-content" style="margin-top:.75rem;">
|
||||
<h5 style="margin:0 0 .35rem 0; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;">Test Hand
|
||||
<span class="muted" style="font-size:12px; font-weight:400;">Draw 7 at random (no repeats except for basic lands).</span>
|
||||
</h5>
|
||||
|
|
@ -506,15 +515,24 @@
|
|||
#test-hand.hand-row-overlap.fan .stack-grid{ padding-left:0; }
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
<style>
|
||||
.chart-tooltip { position: fixed; pointer-events: none; background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); padding: .4rem .55rem; border-radius: 6px; font-size: 12px; line-height: 1.3; white-space: pre-line; z-index: 9999; display: none; box-shadow: 0 4px 16px rgba(0,0,0,.4); }
|
||||
.chart-tooltip { position: fixed; background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); padding: .4rem .55rem; border-radius: 6px; font-size: 12px; line-height: 1.3; white-space: pre-line; z-index: 9999; display: none; box-shadow: 0 4px 16px rgba(0,0,0,.4); max-width: 90vw; }
|
||||
/* Pinned tooltip gets pointer events for Copy button */
|
||||
.chart-tooltip.pinned { pointer-events: auto; border-color: #f59e0b; box-shadow: 0 4px 20px rgba(245,158,11,.3); }
|
||||
/* Unpinned tooltip has no pointer events (hover only) */
|
||||
.chart-tooltip:not(.pinned) { pointer-events: none; }
|
||||
/* Cross-highlight from charts to cards */
|
||||
.chart-highlight { border-radius: 6px; background: rgba(245,158,11,.08); box-shadow: 0 0 0 2px #f59e0b inset; }
|
||||
/* For list view, ensure baseline padding so no layout shift on highlight */
|
||||
#typeview-list .list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; }
|
||||
/* Ensure stack-card gets visible highlight */
|
||||
.stack-card.chart-highlight { box-shadow: 0 0 0 2px #f59e0b, 0 6px 18px rgba(0,0,0,.55); }
|
||||
/* Chart columns get cursor pointer */
|
||||
.chart-column svg { cursor: pointer; transition: opacity 0.15s ease; }
|
||||
.chart-column svg:hover { opacity: 0.85; }
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
|
|
@ -532,53 +550,72 @@
|
|||
var hoverTimer = null;
|
||||
var lastNames = [];
|
||||
var lastType = '';
|
||||
var pinnedNames = [];
|
||||
var pinnedType = '';
|
||||
var pinnedEl = null;
|
||||
function clearHoverTimer(){ if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } }
|
||||
function position(e) {
|
||||
tip.style.display = 'block';
|
||||
var x = e.clientX + 12, y = e.clientY + 12;
|
||||
tip.style.left = x + 'px';
|
||||
tip.style.top = y + 'px';
|
||||
var rect = tip.getBoundingClientRect();
|
||||
var vw = window.innerWidth || document.documentElement.clientWidth;
|
||||
var vh = window.innerHeight || document.documentElement.clientHeight;
|
||||
if (x + rect.width + 8 > vw) tip.style.left = (e.clientX - rect.width - 12) + 'px';
|
||||
if (y + rect.height + 8 > vh) tip.style.top = (e.clientY - rect.height - 12) + 'px';
|
||||
var isMobile = vw < 768;
|
||||
|
||||
if (isMobile) {
|
||||
// Mobile: fixed to lower-right corner
|
||||
tip.style.right = '8px';
|
||||
tip.style.bottom = '8px';
|
||||
tip.style.left = 'auto';
|
||||
tip.style.top = 'auto';
|
||||
tip.style.maxWidth = 'calc(100vw - 16px)';
|
||||
} else {
|
||||
// Desktop: fixed to lower-left corner
|
||||
tip.style.left = '8px';
|
||||
tip.style.bottom = '8px';
|
||||
tip.style.right = 'auto';
|
||||
tip.style.top = 'auto';
|
||||
tip.style.maxWidth = '400px';
|
||||
}
|
||||
}
|
||||
function buildTip(el) {
|
||||
// Render tooltip with safe DOM and a Copy button for card list
|
||||
function buildTip(el, isPinned) {
|
||||
// Render tooltip with safe DOM
|
||||
tip.innerHTML = '';
|
||||
var t = el.getAttribute('data-type');
|
||||
var header = document.createElement('div');
|
||||
header.style.fontWeight = '600';
|
||||
header.style.marginBottom = '.25rem';
|
||||
header.style.display = 'flex';
|
||||
header.style.alignItems = 'center';
|
||||
header.style.justifyContent = 'space-between';
|
||||
header.style.gap = '.5rem';
|
||||
|
||||
var titleSpan = document.createElement('span');
|
||||
var listText = '';
|
||||
if (t === 'pips') {
|
||||
header.textContent = el.dataset.color + ': ' + (el.dataset.count || '0') + ' (' + (el.dataset.pct || '0') + '%)';
|
||||
titleSpan.textContent = el.dataset.color + ': ' + (el.dataset.count || '0') + ' (' + (el.dataset.pct || '0') + '%)';
|
||||
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
|
||||
} else if (t === 'sources') {
|
||||
header.textContent = el.dataset.color + ': ' + (el.dataset.val || '0') + ' (' + (el.dataset.pct || '0') + '%)';
|
||||
titleSpan.textContent = el.dataset.color + ': ' + (el.dataset.val || '0') + ' (' + (el.dataset.pct || '0') + '%)';
|
||||
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
|
||||
} else if (t === 'curve') {
|
||||
header.textContent = el.dataset.label + ': ' + (el.dataset.val || '0') + ' (' + (el.dataset.pct || '0') + '%)';
|
||||
titleSpan.textContent = el.dataset.label + ': ' + (el.dataset.val || '0') + ' (' + (el.dataset.pct || '0') + '%)';
|
||||
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
|
||||
} else {
|
||||
header.textContent = el.getAttribute('aria-label') || '';
|
||||
titleSpan.textContent = el.getAttribute('aria-label') || '';
|
||||
}
|
||||
tip.appendChild(header);
|
||||
if (listText) {
|
||||
var pre = document.createElement('pre');
|
||||
pre.style.margin = '0 0 .35rem 0';
|
||||
pre.style.whiteSpace = 'pre-wrap';
|
||||
pre.textContent = listText;
|
||||
tip.appendChild(pre);
|
||||
header.appendChild(titleSpan);
|
||||
|
||||
// Add Copy button that works with pinned tooltips
|
||||
if (listText && isPinned) {
|
||||
var btn = document.createElement('button');
|
||||
btn.textContent = 'Copy';
|
||||
btn.style.fontSize = '12px';
|
||||
btn.style.padding = '.2rem .4rem';
|
||||
btn.style.fontSize = '11px';
|
||||
btn.style.padding = '.15rem .35rem';
|
||||
btn.style.border = '1px solid var(--border)';
|
||||
btn.style.background = '#12161c';
|
||||
btn.style.color = '#e5e7eb';
|
||||
btn.style.borderRadius = '4px';
|
||||
btn.style.cursor = 'pointer';
|
||||
btn.style.flexShrink = '0';
|
||||
btn.addEventListener('click', function(e){
|
||||
e.stopPropagation();
|
||||
try {
|
||||
|
|
@ -592,7 +629,28 @@
|
|||
setTimeout(function(){ btn.textContent = 'Copy'; }, 1200);
|
||||
} catch(_) {}
|
||||
});
|
||||
tip.appendChild(btn);
|
||||
header.appendChild(btn);
|
||||
}
|
||||
|
||||
tip.appendChild(header);
|
||||
if (listText) {
|
||||
var pre = document.createElement('pre');
|
||||
pre.style.margin = '.25rem 0 0 0';
|
||||
pre.style.whiteSpace = 'pre-wrap';
|
||||
pre.style.fontSize = '12px';
|
||||
pre.textContent = listText;
|
||||
tip.appendChild(pre);
|
||||
}
|
||||
|
||||
// Add hint for pinning on desktop
|
||||
if (!isPinned && window.innerWidth >= 768) {
|
||||
var hint = document.createElement('div');
|
||||
hint.style.marginTop = '.35rem';
|
||||
hint.style.fontSize = '11px';
|
||||
hint.style.color = '#9ca3af';
|
||||
hint.style.fontStyle = 'italic';
|
||||
hint.textContent = 'Click to pin';
|
||||
tip.appendChild(hint);
|
||||
}
|
||||
}
|
||||
function normalizeList(list) {
|
||||
|
|
@ -605,41 +663,114 @@
|
|||
return s.trim();
|
||||
}).filter(Boolean);
|
||||
}
|
||||
function unpin() {
|
||||
if (pinnedEl) {
|
||||
pinnedEl.style.outline = '';
|
||||
pinnedEl = null;
|
||||
}
|
||||
if (pinnedNames && pinnedNames.length) {
|
||||
highlightNames(pinnedNames, false);
|
||||
}
|
||||
pinnedNames = [];
|
||||
pinnedType = '';
|
||||
tip.classList.remove('pinned');
|
||||
tip.style.display = 'none';
|
||||
}
|
||||
|
||||
function pin(el, e) {
|
||||
// Unpin previous if different element
|
||||
if (pinnedEl && pinnedEl !== el) {
|
||||
unpin();
|
||||
}
|
||||
|
||||
// Toggle: if clicking same element, unpin
|
||||
if (pinnedEl === el) {
|
||||
unpin();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pin new element
|
||||
pinnedEl = el;
|
||||
el.style.outline = '2px solid #f59e0b';
|
||||
el.style.outlineOffset = '2px';
|
||||
|
||||
var dataType = el.getAttribute('data-type');
|
||||
pinnedNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
|
||||
pinnedType = dataType;
|
||||
|
||||
tip.classList.add('pinned');
|
||||
buildTip(el, true);
|
||||
position(e);
|
||||
highlightNames(pinnedNames, true);
|
||||
}
|
||||
|
||||
function attach() {
|
||||
document.querySelectorAll('[data-type]').forEach(function(el) {
|
||||
// Attach to SVG elements with data-type for better hover zones
|
||||
document.querySelectorAll('svg[data-type]').forEach(function(el) {
|
||||
el.addEventListener('mouseenter', function(e) {
|
||||
buildTip(el);
|
||||
// Don't show hover tooltip if this element is pinned
|
||||
if (pinnedEl === el) return;
|
||||
|
||||
clearHoverTimer();
|
||||
buildTip(el, false);
|
||||
position(e);
|
||||
// Cross-highlight for mana curve bars -> card items
|
||||
try {
|
||||
if (el.getAttribute('data-type') === 'curve') {
|
||||
var dataType = el.getAttribute('data-type');
|
||||
if (dataType === 'curve' || dataType === 'pips' || dataType === 'sources') {
|
||||
lastNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
|
||||
lastType = 'curve';
|
||||
highlightNames(lastNames, true);
|
||||
} else if (el.getAttribute('data-type') === 'pips' || el.getAttribute('data-type') === 'sources') {
|
||||
lastNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
|
||||
lastType = el.getAttribute('data-type');
|
||||
highlightNames(lastNames, true);
|
||||
lastType = dataType;
|
||||
// Only apply hover highlights if nothing is pinned
|
||||
if (!pinnedEl) {
|
||||
highlightNames(lastNames, true);
|
||||
}
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
el.addEventListener('mousemove', position);
|
||||
|
||||
el.addEventListener('mousemove', function(e) {
|
||||
if (pinnedEl === el) return;
|
||||
position(e);
|
||||
});
|
||||
|
||||
el.addEventListener('mouseleave', function() {
|
||||
// Don't hide if pinned
|
||||
if (pinnedEl) return;
|
||||
|
||||
clearHoverTimer();
|
||||
hoverTimer = setTimeout(function(){
|
||||
tip.style.display = 'none';
|
||||
try { if (lastNames && lastNames.length) highlightNames(lastNames, false); } catch(_) {}
|
||||
try { if (lastNames && lastNames.length && !pinnedEl) highlightNames(lastNames, false); } catch(_) {}
|
||||
lastNames = []; lastType = '';
|
||||
}, 200);
|
||||
});
|
||||
|
||||
el.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
pin(el, e);
|
||||
});
|
||||
});
|
||||
// Keep tooltip open while hovering it (for pinned tooltips with Copy button)
|
||||
tip.addEventListener('mouseenter', function(){
|
||||
clearHoverTimer();
|
||||
});
|
||||
// Keep tooltip open while hovering it
|
||||
tip.addEventListener('mouseenter', function(){ clearHoverTimer(); });
|
||||
tip.addEventListener('mouseleave', function(){
|
||||
// Don't hide if pinned
|
||||
if (pinnedEl) return;
|
||||
tip.style.display = 'none';
|
||||
try { if (lastNames && lastNames.length) highlightNames(lastNames, false); } catch(_) {}
|
||||
lastNames = []; lastType = '';
|
||||
});
|
||||
|
||||
// Click outside to unpin
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!pinnedEl) return;
|
||||
// Don't unpin if clicking the tooltip itself or a chart
|
||||
if (tip.contains(e.target) || e.target.closest('svg[data-type]')) return;
|
||||
unpin();
|
||||
});
|
||||
|
||||
// Initialize Show C toggle
|
||||
initShowCToggle();
|
||||
}
|
||||
|
|
@ -663,9 +794,9 @@
|
|||
}
|
||||
function highlightNames(names, on){
|
||||
if (!Array.isArray(names) || names.length === 0) return;
|
||||
// List view spans
|
||||
// List view spans - target only the .name span, not the parent .list-row
|
||||
try {
|
||||
document.querySelectorAll('#typeview-list [data-card-name]').forEach(function(it){
|
||||
document.querySelectorAll('#typeview-list .list-row .name[data-card-name]').forEach(function(it){
|
||||
var n = it.getAttribute('data-card-name');
|
||||
if (!n) return;
|
||||
var match = names.indexOf(n) !== -1;
|
||||
|
|
@ -695,4 +826,5 @@
|
|||
attach();
|
||||
document.addEventListener('htmx:afterSwap', function() { attach(); });
|
||||
})();
|
||||
</script>
|
||||
</script>
|
||||
</div>
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<div id="include-exclude-summary" data-summary>
|
||||
{% set is_oob = oob if oob is defined else False %}
|
||||
<div id="include-exclude-summary" data-summary{% if is_oob %} hx-swap-oob="true"{% endif %}>
|
||||
{% set pending_state = must_have_state if must_have_state is defined else None %}
|
||||
{% set pending_includes = pending_state.includes if pending_state and pending_state.includes is not none else [] %}
|
||||
{% set pending_excludes = pending_state.excludes if pending_state and pending_state.excludes is not none else [] %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue