Files
mgt2-compendium-amiral-denisov/scripts/SectorMapApp.js
T
uberwald 7fc8d2c09c feat: batch NPC import, ally/enemy gen, map sync, UI shortcuts
- batch NPC creator: import images from dir → actors (dedup, folder)
- ally/enemy generator: data tables, D66 specials, tests (39 pass)
- sector map sync: DialogV2 player view, socket v13/v14, sync click pos
- actor/scene directory: shortcut buttons (Commerce, PNJ, Map, Sync vue)
- closeImagePopout: GM closes player portraits via socket
- canvasSync: force same canvas view on all players
- FilePicker: v14 folder picker support with v13 fallback
- cleanup: debug logs, template dedup, return guards
2026-07-09 07:49:12 +02:00

648 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MGT2 SectorMapApp
*
* Application interactive affichant une carte Traveller Map dans un IFRAME.
* Les clics sur la carte affichent les détails du monde dans le chat.
*/
import { searchWorlds } from './travellerMapApi.js';
import { TravelDialog } from './travelDialog.js';
const { ApplicationV2 } = foundry.applications.api;
const MODULE_ID = 'mgt2-compendium-amiral-denisov';
export class SectorMapApp extends ApplicationV2 {
static DEFAULT_OPTIONS = {
id: 'mgt2-sector-map',
classes: ['mgt2-sector-map'],
position: { width: 960, height: 720 },
window: { icon: 'fas fa-map', resizable: true, controls: [] },
};
constructor(sector, subsector, { readOnly = false } = {}) {
super();
this._sector = sector;
this._subsector = subsector;
this._readOnly = readOnly;
this._handler = null;
this._mapHex = null;
this._mapCenter = null;
this._mapScale = null;
this._searchTimeout = null;
}
get title() {
if (!this._sector) return 'Carte stellaire — Traveller Map';
return this._subsector
? `Sous-secteur ${this._subsector}${this._sector}`
: `Secteur ${this._sector}`;
}
get _mapUrl() {
const base = 'https://travellermap.com';
const cb = this._readOnly ? '' : '&callback=postmessage';
if (!this._sector) return `${base}/?style=mongoose&hideui=1`;
if (this._subsector) {
return `${base}/?sector=${encodeURIComponent(this._sector)}&subsector=${encodeURIComponent(this._subsector)}&style=mongoose&hideui=1${cb}`;
}
let url = `${base}/?sector=${encodeURIComponent(this._sector)}&style=mongoose&hideui=1${cb}`;
if (this._mapHex) {
url += `&hex=${this._mapHex}&scale=512`;
} else if (this._mapCenter && this._mapScale) {
url += `&center=${this._mapCenter}&scale=${this._mapScale}`;
}
return url;
}
/* ───── Rendu ───── */
_replaceHTML(result, config) {
const content = this.element?.querySelector('.window-content');
if (!content) return;
const html = typeof result === 'string' ? result : this._lastHTML;
content.innerHTML = typeof html === 'string' ? html : '';
}
_renderHTML() {
if (this._readOnly) {
return `<div class="mgt2-sector-map-outer">
<div class="mgt2-sector-map-toolbar mgt2-sector-map-toolbar--ro">
<span class="mgt2-sector-map-label">${SectorMapApp._escapeAttr(this.title)}</span>
<span class="mgt2-sector-map-hint">Carte partagée par le MJ</span>
</div>
<iframe
src="${this._mapUrl}"
class="mgt2-sector-map-frame"
allow="clipboard-write"
referrerpolicy="no-referrer">
</iframe>
</div>`;
}
return `<div class="mgt2-sector-map-outer">
<div class="mgt2-sector-map-toolbar">
<div class="mgt2-sector-map-search">
<input type="text" class="mgt2-sector-map-input" placeholder="Rechercher un monde…" autocomplete="off">
<ul class="mgt2-sector-map-results"></ul>
</div>
<span class="mgt2-sector-map-label">${this._sector ? `${this._sector}${this._subsector ? ` — ss.${this._subsector}` : ''}` : 'Toute la carte'}</span>
<span class="mgt2-sector-map-hint">Cliquez sur un hex pour voir les détails du monde</span>
<button type="button" class="mgt2-sector-map-sync" disabled title="Cliquez d'abord sur un hex pour définir la position à synchroniser"><i class="fas fa-users"></i> Synchroniser</button>
<button type="button" class="mgt2-sector-map-travel" title="Planifier un voyage"><i class="fas fa-route"></i> Voyage</button>
</div>
<iframe
src="${this._mapUrl}"
class="mgt2-sector-map-frame"
allow="clipboard-write"
referrerpolicy="no-referrer">
</iframe>
</div>`;
}
async _onRender(context, options) {
if (this._readOnly) {
this._listen();
return;
}
this._listen();
this.element?.querySelector('.mgt2-sector-map-sync')?.addEventListener('click', () => {
this._syncAll();
});
this.element?.querySelector('.mgt2-sector-map-travel')?.addEventListener('click', () => {
this._openTravelDialog();
});
const input = this.element?.querySelector('.mgt2-sector-map-input');
const results = this.element?.querySelector('.mgt2-sector-map-results');
if (input && results) {
input.addEventListener('input', () => {
if (this._searchTimeout) clearTimeout(this._searchTimeout);
this._searchTimeout = setTimeout(() => this._doSearch(input, results), 300);
});
input.addEventListener('blur', () => {
setTimeout(() => { results.innerHTML = ''; }, 200);
});
input.addEventListener('focus', () => {
if (input.value.trim().length >= 2) this._doSearch(input, results);
});
}
}
/* ───── Écoute des clics IFRAME ───── */
_listen() {
if (this._handler) return;
this._handler = (event) => {
const d = event.data || {};
if (d.type === 'viewport') {
this._sector = d.sector || this._sector;
this._subsector = d.subsector || this._subsector;
this._mapHex = d.hex || null;
this._mapCenter = (d.x != null && d.y != null) ? `${d.x},${d.y}` : null;
this._mapScale = d.scale || null;
return;
}
// Click on map
const wx = d.x ?? d.location?.x;
const wy = d.y ?? d.location?.y;
if (wx == null || wy == null) return;
const x = Number(wx);
const y = Number(wy);
if (isNaN(x) || isNaN(y)) return;
this._onMapClick({ x, y }).catch(err => {
console.error('SectorMapApp | click handler failed:', err);
});
};
window.addEventListener('message', this._handler);
}
async _onMapClick(loc) {
const wx = loc?.x;
const wy = loc?.y;
if (wx == null || wy == null) return;
const coordResp = await fetch(
`https://travellermap.com/api/coordinates?x=${wx}&y=${wy}`
);
if (!coordResp.ok) { console.error('SectorMapApp | /api/coordinates failed', coordResp.status); return; }
const coord = await coordResp.json();
const { sx, sy, hx, hy } = coord;
if (sx == null || hx == null || hy == null) { console.error('SectorMapApp | no sx/hx/hy in', coord); return; }
const metaResp = await fetch(
`https://travellermap.com/api/metadata?sx=${sx}&sy=${sy}`
);
if (!metaResp.ok) { console.error('SectorMapApp | /api/metadata failed', metaResp.status); return; }
const meta = await metaResp.json();
const sectorName = meta.Names?.[0]?.Text;
if (!sectorName) { console.error('SectorMapApp | no Names[0].Text in metadata', meta); return; }
// Store click position for sync
this._sector = sectorName;
this._subsector = null;
this._mapHex = String(hx).padStart(2, '0') + String(hy).padStart(2, '0');
this._mapCenter = `${wx},${wy}`;
this._mapScale = loc.scale || 512;
const hex = String(hx).padStart(2, '0') + String(hy).padStart(2, '0');
const resp = await fetch(
`https://travellermap.com/data/${encodeURIComponent(sectorName)}/${hex}`
);
if (!resp.ok) { console.error('SectorMapApp | /data failed', resp.status, sectorName, hex); return; }
const data = await resp.json();
const world = data.Worlds?.[0];
if (!world) { console.error('SectorMapApp | no Worlds in data', data); return; }
this._enableSyncButton(world.Name || hex);
this._postWorldCard(world);
}
_enableSyncButton(worldName) {
const btn = this.element?.querySelector('.mgt2-sector-map-sync');
if (btn) {
btn.disabled = false;
btn.title = worldName ? `Synchroniser la vue sur ${worldName}` : 'Ouvrir la carte interactive chez tous les joueurs';
}
}
/* ───── Carte de chat ───── */
static _STARPORT = { A:'Excellent', B:'Bon', C:'Routinier', D:'Médiocre', E:'Frontière', X:'Aucun' };
static _SIZE = ['Aucun (Astéroïde)','1 600 km','3 200 km','4 800 km','6 400 km','8 000 km','9 600 km','11 200 km','12 800 km','14 400 km','16 000 km'];
static _ATMO = [
'Aucune (vide)','Trace','Très ténue (polluée)','Très ténue','Ténue (polluée)','Ténue','Standard','Standard (polluée)','Dense','Dense (polluée)',
'Exotique','Corrosive','Insidieuse','','',''];
static _HYDRO = [ '05% (désert)','615%','1625%','2635%','3645%','4655%','5665%','6675%','7685%','8695%','96100%' ];
static _POP = ['','Dizaines','Centaines','Milliers','Dizaines de milliers','Centaines de milliers','Millions','Dizaines de millions','Centaines de millions','Milliards','Dizaines de milliards','','','','','',''];
static _GOV = [
'Aucun','Compagnie / Corporation','Démocratie participative','Oligarchie auto-perpétuée',
'Démocratie représentative','Technocratie féodale','Gouvernement captif / Colonie',
'Balkanisation','Bureaucratie de service civil','Bureaucratie impersonnelle',
'Dictature charismatique','Dictature non-charismatique','Oligarchie charismatique',
'Dictature religieuse','Oligarchie religieuse','Gouvernement tribal'];
static _LAW = [
'Aucune', 'Armes de poing, explosifs, poison','Armes à énergie portatives','Mitrailleuses, armes auto',
'Armes d\'assaut légères, PM','Armes de poing individuelles','Toutes les armes à feu sauf neutralisateur',
'Fusils, neutralisateur','Armes blanches, neutralisateur','Armes hors du domicile','Armes interdites',
'Contrôle rigide','Aucune arme','Contrôle militariste sévère'];
static _TL = [
'Âge de pierre','Âge du bronze/fer','Médiéval','Grandes découvertes','Révolution industrielle',
'Production mécanisée','Ère nucléaire','Pré-stellaire (ère de l\'information)','Propulsion à saut (1re gen)',
'Propulsion à saut-2','Propulsion à saut-3','Propulsion à saut-4','Propulsion à saut-5',
'Propulsion à saut-6','Transporteur','Moyenne stellaire'];
static _hexVal(ch) {
const n = parseInt(ch, 36);
if (isNaN(n)) return -1;
return n;
}
static _uwpDigit(desc, val) {
return `<span class="uwp-dig" title="${desc}">${val}</span>`;
}
static _uwpBreakdown(uwp) {
if (!uwp || uwp.length < 2) return '';
const d = SectorMapApp._hexVal;
const s = d(uwp[0]), sz = d(uwp[1]), a = d(uwp[2]), h = d(uwp[3]);
const p = d(uwp[4]), g = d(uwp[5]), l = d(uwp[6]);
const t = uwp.length > 8 ? d(uwp[8]) : -1;
const lines = [];
const starport = SectorMapApp._STARPORT[uwp[0]];
lines.push(`<tr><td>${uwp[0]}</td><td>Starport</td><td>${starport ?? '—'}</td></tr>`);
if (uwp[1] === 'F' || uwp[1] === 'f') {
lines.push(`<tr><td>${uwp[1]}</td><td>Taille</td><td>Gaz géant</td></tr>`);
} else if (sz >= 0 && sz <= 10) {
const km = SectorMapApp._SIZE[sz];
const grav = sz === 0 ? '0g' : (sz < 10 ? `0.${sz}g` : '1.0g+');
lines.push(`<tr><td>${uwp[1]}</td><td>Taille</td><td>${km} (${grav})</td></tr>`);
}
if (a >= 0 && a <= 15) {
const atmo = SectorMapApp._ATMO[a] ?? '—';
lines.push(`<tr><td>${uwp[2]}</td><td>Atmosphère</td><td>${atmo}</td></tr>`);
}
if (h >= 0 && h <= 10) {
lines.push(`<tr><td>${uwp[3]}</td><td>Hydrosphère</td><td>${SectorMapApp._HYDRO[h]}</td></tr>`);
}
if (p >= 0 && p <= 15) {
lines.push(`<tr><td>${uwp[4]}</td><td>Population</td><td>${SectorMapApp._POP[p] ?? '—'}</td></tr>`);
}
if (g >= 0 && g <= 15) {
lines.push(`<tr><td>${uwp[5]}</td><td>Gouvernement</td><td>${SectorMapApp._GOV[g] ?? '—'}</td></tr>`);
}
if (l >= 0 && l <= 15) {
lines.push(`<tr><td>${uwp[6]}</td><td>Niveau légal</td><td>${SectorMapApp._LAW[l] ?? '—'}</td></tr>`);
}
if (t >= 0 && t <= 15) {
lines.push(`<tr><td>${uwp[8]}</td><td>Technologie</td><td>${SectorMapApp._TL[t] ?? '—'}</td></tr>`);
}
return `<table class="uwp-breakdown"><tbody>${lines.join('')}</tbody></table>`;
}
static _REMARKS_HELP = {
AB:'Anneau (ceinture)', AG:'Agricole', AN:'Site ancien', AS:'Astéroïde',
BA:'Bande astéroïdale', CP:'Sous-secteur capitale', CS:'Colonie',
CX:'Chasseur (Croiseur)', CY:'Colonie', DA:'Déchu', DE:'Désertique',
DI:'Interdit (Diebar)', FL:'Fluides Lo', FO:'Interdit (Forbidden)',
FR:'Gelé (Frozen)', GA:'Jardin (Garden)', HE:'Helios', HI:'Haute population',
HT:'Haute technologie', IC:'Mondes gelés (Ice)', IN:'Industrialisé',
LI:'Faible population', LO:'Faible population (Low)', LT:'Basse technologie (Low Tech)',
MI:'Militaire', MR:'Mine (ressources)', NA:'Non-agricole',
NI:'Non-industrialisé', OC:'Océanique', OX:'Oxydant',
PA:'Pré-agricole (Pre-Agricultural)', PH:'Phosphore',
PO:'Pauvre (Poor)', PR:'Pré-industriel (Pre-Industrial)',
PX:'Prisonnier (exil)', PZ:'Puzzle (énigmatique)',
RE:'Religieux (Religious)', RI:'Riche (Rich)',
SA:'Bande d\'astéroïdes (Satellite)', SC:'Sainte (colonie)',
SL:'Esclavage (Slave)', SO:'Soleil (Sol)', SP:'Désert (Despoiled)',
SR:'Réserve (Reserve)', ST:'Base stellaire', SU:'Secteur capitale',
TR:'Traces (Trace)', TU:'Tucannides', TZ:'Mondes Tz',
UN:'Inhabité (Uninhabited)', VA:'Vide (Vacuum)',
WA:'Monde aquatique (Water)', WT:'Monde d\'eau (Watery)',
};
static _STAR_TYPES = { O:'Bleu (hypergéante)', B:'Bleu-blanc', A:'Blanc', F:'Blanc-jaune', G:'Jaune (naine)', K:'Orange (naine)', M:'Rouge (naine)', L:'Brune', T:'Brune', Y:'Brune' };
static _STAR_CLASS = { 'I':'Supergéante', 'II':'Géante brillante', 'III':'Géante', 'IV':'Sous-géante', 'V':'Naine (séquence principale)', 'VI':'Sous-naine', 'VII':'Naine blanche' };
static _BASES_HELP = {
N:'Base navale', S:'Base scout', W:'Relais', D:'Dépôt naval',
T:'Base TAS', C:'Consulat', P:'Base pirate', R:'Base de réparation',
K:'Base navale (K.)', X:'Relais Xboat',
};
static _NOBILITY = {
B:'Chevalier (Baronet)', C:'Baron', D:'Marquis', E:'Comte', F:'Duc', G:'Archiduc', H:'Empereur',
};
static _IMPORTANCE = {
'-5':'Très mineur', '-4':'Mineur', '-3':'Mineur', '-2':'Très secondaire', '-1':'Secondaire',
'0':'Ordinaire', '1':'Important', '2':'Important', '3':'Très important', '4':'Majeure',
'5':'Majeure', '6':'Capitale',
};
static _foldRow(label, value, detail, titleAttr) {
const valAttr = titleAttr ? ` title="${titleAttr}"` : '';
return `<tr><td colspan="2">
<details>
<summary><span class="fold-label">${label}</span><span class="fold-value"${valAttr}>${value}</span></summary>
<div class="fold-content">${detail}</div>
</details>
</td></tr>`;
}
static _decodeImportance(ix) {
if (!ix) return '';
const m = String(ix).match(/\{?\s*(-?\d+)\s*\}?/);
if (!m) return SectorMapApp._foldRow('Importance', ix, '');
const val = m[1];
const desc = SectorMapApp._IMPORTANCE[val] || '—';
const detail = `<div class="fold-desc">Valeur dimportance économique et stratégique du monde.<br>${val} = ${desc}</div>`;
return SectorMapApp._foldRow('Importance', `{ ${val} } ${desc}`, detail);
}
static _decodeEconomics(ex) {
if (!ex) return '';
let s = String(ex).replace(/[()\s]/g, '');
const m = s.match(/^([\dA-F])([\dA-F])([\dA-F])([+-]\d+)$/i);
if (!m) return SectorMapApp._foldRow('Économie', ex, '');
const res = m[1], lab = m[2], inf = m[3], eff = m[4];
const detail = `<table class="fold-subtable">
<tr><td>Ressources</td><td>${res}</td></tr>
<tr><td>Main-d’œuvre</td><td>${lab}</td></tr>
<tr><td>Infrastructure</td><td>${inf}</td></tr>
<tr><td>Efficacité</td><td>${eff}</td></tr>
</table>`;
return SectorMapApp._foldRow('Économie', `( ${res} ${lab} ${inf} ${eff} )`, detail);
}
static _decodeCulture(cx) {
if (!cx) return '';
const s = String(cx).replace(/[\[\]\s]/g, '');
if (s.length < 4) return SectorMapApp._foldRow('Culture', cx, '');
const h = s[0].toUpperCase(), t = s[1].toUpperCase(), p = s[2].toUpperCase(), a = s[3].toUpperCase();
const detail = `<table class="fold-subtable">
<tr><td>Hétérogénéité</td><td>${h}</td></tr>
<tr><td>Traditionalisme</td><td>${t}</td></tr>
<tr><td>Progressisme</td><td>${p}</td></tr>
<tr><td>Agressivité</td><td>${a}</td></tr>
</table>`;
return SectorMapApp._foldRow('Culture', `[ ${h} ${t} ${p} ${a} ]`, detail);
}
static _decodePopulation(uwp, pbg) {
if (!uwp || uwp.length < 5) return '';
const popUwp = SectorMapApp._hexVal(uwp[4]);
if (popUwp < 0) return '';
const popPbg = pbg ? parseInt(pbg[0], 10) : null;
const multiplier = popPbg != null && !isNaN(popPbg) ? popPbg : 1;
const base = Math.pow(10, popUwp);
const total = multiplier * base;
const fmtBase = `10<sup>${popUwp}</sup>`;
const fmtMult = multiplier;
const fmtTotal = total >= 1e9 ? `${(total / 1e9).toFixed(1)}&nbsp;milliards`
: total >= 1e6 ? `${(total / 1e6).toFixed(1)}&nbsp;millions`
: total >= 1e3 ? `${(total / 1e3).toFixed(0)}&nbsp;000`
: String(total);
const belts = pbg ? parseInt(pbg[1], 10) : null;
const gas = pbg ? parseInt(pbg[2], 10) : null;
const detail = `<div class="fold-desc">Population = multiplicateur (PBG: <b>${popPbg}</b>) × 10<sup>chiffre UWP (${uwp[4]})</sup><br>
Ceintures dastéroïdes : <b>${belts ?? '?'}</b> &nbsp;|&nbsp; Géantes gazeuses : <b>${gas ?? '?'}</b></div>`;
return SectorMapApp._foldRow('Population', `${fmtMult} × ${fmtBase} = ${fmtTotal}`, detail);
}
static _decodeNobility(nob) {
if (!nob) return '';
const titles = [];
for (const ch of nob) {
const desc = SectorMapApp._NOBILITY[ch.toUpperCase()];
if (desc) titles.push(`${ch} (${desc})`);
}
if (!titles.length) return SectorMapApp._foldRow('Noblesse', nob, '');
const detail = `<div class="fold-desc">Titres de noblesse impériale présents sur ce monde.</div>`;
return SectorMapApp._foldRow('Noblesse', titles.join(', '), detail);
}
static _buildWorldCardHTML(w) {
const sector = w.Sector || '';
const hex = w.Hex || '';
const name = w.Name || '—';
const uwp = w.UWP || '???????-?';
const bases = w.Bases || '';
const remarks = w.Remarks || '';
const allegiance = w.Allegiance || '';
const stellar = w.Stellar || '';
const zone = w.Zone || '';
const pbg = w.PBG || '';
const zoneLabel = zone === 'R' ? 'Rouge'
: zone === 'A' ? 'Ambre'
: 'Verte';
const lines = [];
const uwpDetail = SectorMapApp._uwpBreakdown(uwp);
lines.push(SectorMapApp._foldRow('UWP', `<span class="mono">${uwp}</span>`, uwpDetail));
const ixRow = SectorMapApp._decodeImportance(w.Ix);
if (ixRow) lines.push(ixRow);
const exRow = SectorMapApp._decodeEconomics(w.Ex);
if (exRow) lines.push(exRow);
const cxRow = SectorMapApp._decodeCulture(w.Cx);
if (cxRow) lines.push(cxRow);
const popRow = SectorMapApp._decodePopulation(uwp, pbg);
if (popRow) lines.push(popRow);
const nobRow = SectorMapApp._decodeNobility(w.Nobility);
if (nobRow) lines.push(nobRow);
if (bases) {
const bCodes = bases.split(/[\s,;]+/);
const bList = bCodes.map(c => {
const desc = SectorMapApp._BASES_HELP[c.toUpperCase()];
return `<tr><td class="mono">${c}</td><td>${desc || '—'}</td></tr>`;
}).join('');
const bDetail = `<table class="fold-subtable"><tbody>${bList}</tbody></table>`;
lines.push(SectorMapApp._foldRow('Bases', bases, bDetail));
}
if (remarks) {
const rCodes = remarks.split(/[\s,;]+/);
const rList = rCodes.map(c => {
const desc = SectorMapApp._REMARKS_HELP[c.toUpperCase()];
return `<tr><td class="mono">${c}</td><td>${desc || '—'}</td></tr>`;
}).join('');
const rDetail = `<table class="fold-subtable"><tbody>${rList}</tbody></table>`;
lines.push(SectorMapApp._foldRow('Remarques', remarks, rDetail));
}
if (allegiance) {
const allegFull = w.AllegianceName || '';
const aDetail = `<div class="fold-desc">${allegFull || allegiance}</div>`;
lines.push(SectorMapApp._foldRow('Allégeance', allegFull ? `${allegiance} (${allegFull})` : allegiance, aDetail));
}
if (stellar) {
const sList = [];
let remaining = stellar.trim();
const reStar = /^([OBAFGKMLTY])(\d)\s*(VII|VI|V|IV|III|II|I)\s*/i;
while (remaining) {
const m = remaining.match(reStar);
if (m) {
const type = SectorMapApp._STAR_TYPES[m[1].toUpperCase()] || m[1];
const cls = SectorMapApp._STAR_CLASS[m[3]] || m[3];
sList.push(`<tr><td class="mono">${m[1]}${m[2]} ${m[3]}</td><td>${type} · ${cls}</td></tr>`);
remaining = remaining.slice(m[0].length).trimStart();
} else {
const next = remaining.indexOf(' ');
if (next < 0) break;
remaining = remaining.slice(next + 1).trimStart();
}
}
const sDetail = sList.length ? `<table class="fold-subtable"><tbody>${sList.join('')}</tbody></table>` : `<div class="fold-desc">${stellar}</div>`;
lines.push(SectorMapApp._foldRow('Étoile', `<span class="mono">${stellar}</span>`, sDetail));
}
return `<section class="mgt2-world-card">
<div class="mgt2-world-card-header">
<span class="mgt2-world-name">${name}</span>
<span class="mgt2-world-hex">${sector} ${hex}</span>
<span class="mgt2-world-zone zone-${zone.toLowerCase() || 'g'}">${zoneLabel}</span>
</div>
<table class="mgt2-world-card-body"><tbody>${lines.join('')}</tbody></table>
<div class="mgt2-world-card-actions">
<a class="mgt2-world-commerce" data-sector="${this._escapeAttr(sector)}" data-hex="${hex}" data-uwp="${uwp}" data-zone="${zone || 'normal'}" data-name="${this._escapeAttr(name)}">
<i class="fas fa-balance-scale"></i> Commerce
</a>
</div>
</section>`;
}
static _escapeAttr(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
_postWorldCard(w) {
const html = SectorMapApp._buildWorldCardHTML(w);
ChatMessage.create({
content: html,
whisper: [game.user.id],
});
}
/* ───── Recherche de monde ───── */
async _doSearch(input, results) {
const query = input.value.trim();
if (query.length < 2) { results.innerHTML = ''; return; }
const worlds = await searchWorlds(query);
if (!worlds.length) {
results.innerHTML = '<li class="no-result">Aucun monde trouvé</li>';
return;
}
results.innerHTML = worlds.slice(0, 12).map(w =>
`<li data-sector="${w.sector}" data-hex="${w.hex}" data-name="${w.name}">
<span class="world-name">${w.name}</span>
<span class="world-uwp">${w.uwp}</span>
<span class="world-sector">${w.sector}</span>
</li>`
).join('');
results.querySelectorAll('li[data-sector]').forEach(li => {
li.addEventListener('click', () => this._selectWorld(li, input, results));
});
}
async _selectWorld(li, input, results) {
const sector = li.dataset.sector;
const hex = li.dataset.hex;
const name = li.dataset.name;
results.innerHTML = '';
input.value = name;
this._sector = sector;
this._subsector = null;
this._mapHex = hex;
this._mapCenter = null;
this._mapScale = null;
this._enableSyncButton(name);
const iframe = this.element?.querySelector('.mgt2-sector-map-frame');
if (iframe) iframe.src = this._mapUrl;
ui.notifications.info(`Carte centrée sur ${name} (${sector} ${hex})`);
}
/* ───── Partage ───── */
async _syncAll() {
if (!game.socket?.emit) {
ui.notifications.warn('Socket indisponible — impossible de synchroniser');
return;
}
try {
// Tentative de récupération du zoom via getViewport
const viewport = await new Promise((resolve) => {
const handler = (event) => {
if (event.data?.type === 'viewport') {
window.removeEventListener('message', handler);
resolve(event.data);
}
};
window.addEventListener('message', handler);
const iframe = this.element?.querySelector('.mgt2-sector-map-frame');
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage(
{ source: 'travellermap', type: 'getViewport' },
'*'
);
}
setTimeout(() => {
window.removeEventListener('message', handler);
resolve(null);
}, 1000);
});
if (viewport) {
this._sector = viewport.sector || this._sector;
this._subsector = viewport.subsector || this._subsector;
this._mapHex = viewport.hex || this._mapHex;
this._mapCenter = (viewport.x != null && viewport.y != null) ? `${viewport.x},${viewport.y}` : this._mapCenter;
this._mapScale = viewport.scale || this._mapScale;
}
const payload = {
_type: 'sectorMapSync',
sector: this._sector,
subsector: this._subsector,
mapHex: this._mapHex,
mapCenter: this._mapCenter,
mapScale: this._mapScale,
};
const players = game.users.filter(u => !u.isGM && u.active);
for (const player of players) {
game.socket.emit(`module.${MODULE_ID}`, payload);
}
ui.notifications.info(`Vue synchronisée chez ${players.length} joueur(s)`);
} catch (err) {
console.error(`${MODULE_ID} | Erreur socket sync:`, err);
ui.notifications.error('Erreur lors de la synchronisation');
}
}
updatePosition(sector, subsector, mapHex, mapUrl) {
this._sector = sector;
this._subsector = subsector;
this._mapHex = mapHex || null;
const iframe = this.element?.querySelector('.mgt2-sector-map-frame');
if (iframe) iframe.src = mapUrl || this._mapUrl;
}
_openTravelDialog() {
const existing = Object.values(ui.windows).find(w => w.id === 'mgt2-travel-dialog');
if (existing) { existing.bringToTop(); return; }
const dialog = new TravelDialog();
dialog.render({ force: true });
}
/* ───── Nettoyage ───── */
close() {
if (this._handler) {
window.removeEventListener('message', this._handler);
this._handler = null;
}
return super.close();
}
}