diff --git a/api/fight.php b/api/fight.php index 8076e83..64334bb 100644 --- a/api/fight.php +++ b/api/fight.php @@ -42,6 +42,7 @@ query GetReportData($reportCode: String!) { endTime fights { id + encounterID name startTime endTime @@ -75,9 +76,7 @@ function localized_graphql_uri(string $language): string { return preg_replace('#https://[^/]+#', 'https://' . $host, GRAPHQL_URI); } -// Fight names must be stable regardless of language — always use the English endpoint. -// Localization only matters for ability/player names in analysis.php. -$ch = curl_init(GRAPHQL_URI); +$ch = curl_init(localized_graphql_uri($language)); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, @@ -85,6 +84,7 @@ curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . $_SESSION['access_token'], + 'Accept-Language: ' . ($language === 'jp' ? 'ja' : $language), ], CURLOPT_SSL_VERIFYPEER => !DEV_MODE, ]); diff --git a/assets/jsons/Action.json b/assets/jsons/Action.json new file mode 100644 index 0000000..dc75ce4 --- /dev/null +++ b/assets/jsons/Action.json @@ -0,0 +1,150 @@ +{ + "185": { + "cast": 20, + "recast": 25 + }, + "188": { + "cast": 0, + "recast": 300 + }, + "3540": { + "cast": 0, + "recast": 900 + }, + "3613": { + "cast": 0, + "recast": 600 + }, + "7385": { + "cast": 0, + "recast": 1200 + }, + "7388": { + "cast": 0, + "recast": 900 + }, + "7405": { + "cast": 0, + "recast": 1200 + }, + "7432": { + "cast": 0, + "recast": 300 + }, + "7535": { + "cast": 0, + "recast": 600 + }, + "7549": { + "cast": 0, + "recast": 900 + }, + "7560": { + "cast": 0, + "recast": 900 + }, + "16012": { + "cast": 0, + "recast": 1200 + }, + "16160": { + "cast": 0, + "recast": 900 + }, + "16471": { + "cast": 0, + "recast": 900 + }, + "16536": { + "cast": 0, + "recast": 1200 + }, + "16538": { + "cast": 0, + "recast": 1200 + }, + "16548": { + "cast": 0, + "recast": 30 + }, + "16556": { + "cast": 0, + "recast": 300 + }, + "16559": { + "cast": 0, + "recast": 1200 + }, + "16889": { + "cast": 0, + "recast": 1200 + }, + "24291": { + "cast": 0, + "recast": 15 + }, + "24292": { + "cast": 0, + "recast": 15 + }, + "24298": { + "cast": 0, + "recast": 300 + }, + "24305": { + "cast": 0, + "recast": 1200 + }, + "24310": { + "cast": 0, + "recast": 1200 + }, + "24311": { + "cast": 0, + "recast": 1200 + }, + "25751": { + "cast": 0, + "recast": 250 + }, + "25789": { + "cast": 0, + "recast": 15 + }, + "25799": { + "cast": 0, + "recast": 600 + }, + "25857": { + "cast": 0, + "recast": 1200 + }, + "25868": { + "cast": 0, + "recast": 1200 + }, + "34685": { + "cast": 0, + "recast": 1200 + }, + "34686": { + "cast": 0, + "recast": 10 + }, + "36920": { + "cast": 0, + "recast": 1200 + }, + "37011": { + "cast": 0, + "recast": 10 + }, + "37025": { + "cast": 0, + "recast": 10 + }, + "37034": { + "cast": 0, + "recast": 15 + } +} diff --git a/css/planner.css b/css/planner.css index 9477db9..b5a10df 100644 --- a/css/planner.css +++ b/css/planner.css @@ -4,6 +4,12 @@ grid-template-columns: 280px 1fr; gap: 16px; align-items: start; + min-width: 0; +} + +#plan-detail-panel, +#plan-content { + min-width: 0; } /* ── Plan Sidebar ────────────────────────────────────────────────────────────── */ @@ -659,3 +665,318 @@ } .folder-picker-option:hover { background: var(--bg2); color: var(--t1); } .folder-picker-option.active { color: var(--gold); } + +/* ── Planner Timeline ───────────────────────────────────────────────────────── */ +.timeline-hint { + font-size: 12px; + color: var(--t3); +} + +.timeline-empty, +.timeline-settings-empty { + font-size: 13px; + color: var(--t3); + padding: 12px 0; +} + +.timeline-scroll { + overflow-x: auto; + overflow-y: hidden; + border: 1px solid var(--border); + background: var(--bg1); + max-width: 100%; + width: 100%; +} + +.timeline-grid { + width: calc(180px + var(--timeline-width)); + display: grid; + grid-template-columns: 180px var(--timeline-width); +} + +.timeline-row, +.timeline-axis { + display: grid; + grid-template-columns: 180px var(--timeline-width); + min-height: 38px; + grid-column: 1 / -1; +} + +.timeline-row-label { + display: flex; + align-items: center; + gap: 8px; + padding: 0 10px; + background: var(--bgcard); + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + font-size: 12px; + color: var(--t2); + min-width: 0; +} + +.timeline-boss-row { + min-height: var(--boss-row-height, 52px); +} + +.timeline-boss-row .timeline-row-label { + color: var(--gold); + font-weight: 600; +} + +.timeline-track, +.timeline-axis-track { + position: relative; + min-height: inherit; + border-bottom: 1px solid var(--border); + background: + repeating-linear-gradient( + to right, + transparent 0, + transparent 79px, + rgba(255,255,255,0.07) 80px + ); +} + +.timeline-hit-line { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + transform: translateX(-50%); + background: rgba(200,168,75,.38); + pointer-events: none; + z-index: 1; +} + +.timeline-player-row .timeline-track { + background-color: rgba(255,255,255,0.015); +} + +.timeline-job { + width: 36px; + text-align: center; + border-radius: 3px; + padding: 2px 0; + font-size: 11px; + flex-shrink: 0; +} + +.timeline-player-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.timeline-boss-action { + position: absolute; + top: 8px; + transform: translateX(-50%); + max-width: 150px; + padding: 5px 8px; + border: 1px solid rgba(224,92,92,.35); + border-radius: var(--r); + background: rgba(224,92,92,.14); + color: var(--t1); + font-size: 12px; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + z-index: 3; +} + +.timeline-boss-action:hover { + border-color: rgba(224,92,92,.7); + background: rgba(224,92,92,.22); +} + +.timeline-mitigation { + position: absolute; + top: 6px; + width: var(--cd-width); + min-width: 28px; + height: 26px; + display: flex; + align-items: center; + gap: 4px; + padding: 0 6px; + border: 1px solid var(--borderem); + border-radius: var(--r); + background: var(--bg2); + color: var(--t1); + font-size: 11px; + cursor: grab; + z-index: 4; + overflow: hidden; +} + +.timeline-mitigation-active { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: var(--active-width); + background: currentColor; + opacity: 0.22; + pointer-events: none; +} + +.timeline-mitigation:active { cursor: grabbing; } +.timeline-mitigation.selected { + outline: 2px solid var(--gold); + outline-offset: 1px; +} + +.timeline-mitigation--candidate { + border-style: dashed; + opacity: 0.78; +} + +.timeline-mitigation img { + width: 18px; + height: 18px; + object-fit: contain; + flex-shrink: 0; + position: relative; + z-index: 1; +} + +.timeline-mitigation span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + position: relative; + z-index: 1; +} + +.timeline-mitigation .timeline-mitigation-active { + z-index: 0; +} + +.timeline-mitigation--buff { border-color: rgba(200,168,75,.5); color: var(--gold); background: rgba(200,168,75,.12); } +.timeline-mitigation--debuff { border-color: rgba(224,92,92,.5); color: var(--red); background: rgba(224,92,92,.12); } +.timeline-mitigation--shield { border-color: rgba(74,158,255,.5); color: var(--blue); background: rgba(74,158,255,.12); } + +.timeline-axis { + min-height: 28px; +} + +.timeline-axis-track { + border-bottom: none; +} + +.timeline-tick { + position: absolute; + top: 6px; + transform: translateX(-50%); + font-size: 11px; + color: var(--t3); +} + +.timeline-settings-panel { + display: flex; + align-items: end; + flex-wrap: wrap; + gap: 10px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.timeline-settings-title { + font-size: 13px; + color: var(--t1); + font-weight: 600; + width: 100%; +} + +.timeline-settings-meta { + font-size: 12px; + color: var(--t3); + width: 100%; + margin-top: -6px; +} + +.timeline-settings-panel label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 11px; + color: var(--t3); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.timeline-setting-input { + width: 86px !important; + font-size: 13px !important; + padding: 5px 7px !important; +} + +.timeline-context-menu { + position: fixed; + z-index: 300; + min-width: 190px; + max-width: 280px; + padding: 5px; + border: 1px solid var(--borderem); + border-radius: var(--r); + background: var(--bgcard); + box-shadow: 0 10px 24px rgba(0,0,0,0.45); +} + +.timeline-menu-item { + width: 100%; + display: flex; + align-items: center; + gap: 7px; + padding: 7px 9px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--t2); + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.timeline-menu-item:hover { + background: var(--bg2); + color: var(--t1); +} + +.timeline-menu-item.disabled, +.timeline-menu-item:disabled { + opacity: 0.38; + cursor: not-allowed; +} + +.timeline-menu-item img { + width: 18px; + height: 18px; + object-fit: contain; + flex-shrink: 0; +} + +.timeline-menu-item span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.timeline-menu-empty { + padding: 8px 10px; + color: var(--t3); + font-size: 12px; +} + +@media (max-width: 980px) { + .planner-layout { + grid-template-columns: 1fr; + } + + .plan-sidebar { + position: static; + } +} diff --git a/js/analysis.js b/js/analysis.js index 696b622..1ab74c9 100644 --- a/js/analysis.js +++ b/js/analysis.js @@ -138,13 +138,24 @@ return String(name ?? '').trim().toLowerCase(); } - function currentFightName() { - const fight = (window.App?.fights ?? []).find(f => f.id === window.App?.fightId); - return normalizeFightName(fight?.name); + function fightEncounterId(fight) { + return parseInt(fight?.encounterID ?? fight?.encounterId ?? 0, 10) || 0; } - function isSameFightName(fight) { - const name = currentFightName(); + function currentFight() { + return (window.App?.fights ?? []).find(f => f.id === window.App?.fightId) ?? null; + } + + function isSameEncounter(fight) { + const selectedFight = currentFight(); + const selectedEncounterId = fightEncounterId(selectedFight); + const encounterId = fightEncounterId(fight); + + if (selectedEncounterId && encounterId) { + return encounterId === selectedEncounterId; + } + + const name = normalizeFightName(selectedFight?.name); return name !== '' && normalizeFightName(fight?.name) === name; } @@ -341,7 +352,7 @@ let allSameReportFights = []; function populateRefFightSelect() { - const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameFightName(f)); + const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameEncounter(f)); refFightSelect.innerHTML = ''; visible.forEach(f => { const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?'); @@ -410,7 +421,7 @@ extReportCode = code; updateRefFflogsLink(); - const visibleExt = fights.filter(isSameFightName); + const visibleExt = fights.filter(isSameEncounter); refExtFightSelect.innerHTML = ''; visibleExt.forEach(f => { const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?'); diff --git a/js/planner.js b/js/planner.js index cb1d817..f859ef3 100644 --- a/js/planner.js +++ b/js/planner.js @@ -96,6 +96,10 @@ function copyPlan(id) { let activePlanId = null; let collapsedFolders = new Set(); +let selectedTimelineAssignment = null; +let actionMetaPromise = null; +let actionMetaById = {}; +let actionMetaByName = {}; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -129,6 +133,48 @@ function plannerLanguage() { return window.App?.language || localStorage.getItem('ff14-mitigator-language') || 'en'; } +async function ensureActionMetaLoaded() { + if (actionMetaPromise) return actionMetaPromise; + actionMetaPromise = (async () => { + let compact = {}; + let full = {}; + try { + const res = await fetch('assets/jsons/Action.json', { cache: 'no-cache' }); + if (res.ok) compact = await res.json(); + } catch { } + try { + const res = await fetch('assets/mitigation-actions.json', { cache: 'no-cache' }); + if (res.ok) full = await res.json(); + } catch { } + + const byId = {}; + const byName = {}; + for (const [id, action] of Object.entries(full ?? {})) { + const description = action.Description_en ?? ''; + const durations = [...description.matchAll(/Duration:\s*(\d+)s/gi)].map(m => parseInt(m[1], 10)); + const meta = { + id, + name: action.Name_en ?? '', + cast: (parseInt(compact?.[id]?.cast ?? action.Cast100ms ?? 0, 10) || 0) / 10, + recast: (parseInt(compact?.[id]?.recast ?? action.Recast100ms ?? 0, 10) || 0) / 10, + duration: durations.find(Number.isFinite) ?? 15, + }; + byId[id] = meta; + if (meta.name) byName[meta.name] = meta; + } + for (const [id, action] of Object.entries(compact ?? {})) { + byId[id] = { + ...(byId[id] ?? { id }), + cast: (parseInt(action.cast ?? 0, 10) || 0) / 10, + recast: (parseInt(action.recast ?? 0, 10) || 0) / 10, + }; + } + actionMetaById = byId; + actionMetaByName = byName; + })(); + return actionMetaPromise; +} + function sameMechanic(existing, incoming, source) { const fightStart = source?.fightStart ?? 0; const incomingRel = incoming.timestamp - fightStart; @@ -138,6 +184,12 @@ function sameMechanic(existing, incoming, source) { return Math.abs(existing.timestamp - incomingRel) < 1500; } +function visiblePlanMechanics(plan) { + return [...(plan?.mechanics ?? [])] + .filter(m => m?.id && String(m.name ?? '').trim() !== '' && Number.isFinite(Number(m.timestamp))) + .sort((a, b) => a.timestamp - b.timestamp); +} + // ── Rendering: Plan List ────────────────────────────────────────────────────── function planItemHtml(p) { @@ -313,6 +365,8 @@ function renderPlanDetail(plan) { noplan.style.display = 'none'; content.style.display = ''; + const visibleMechanics = visiblePlanMechanics(plan); + content.innerHTML = `