poi-plugin-compass 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -26,6 +26,8 @@ const mapAreas = [
26
26
  ['4', '西方'], ['5', '南方'], ['6', '中部'],
27
27
  ]
28
28
  const LOCAL_KCNAV_ASSET_ROOT = path.join(__dirname, 'assets', 'kcnav')
29
+ const LOCAL_PROPHET_SPOT_ICON_ROOT = path.join(__dirname, 'assets', 'icons', 'spot')
30
+ const PREFERENCES_STORAGE_KEY = 'poi-plugin-compass.preferences.v1'
29
31
 
30
32
  const MAP_PHASES = {
31
33
  '5-6': [
@@ -67,7 +69,6 @@ const {
67
69
  evaluateMap,
68
70
  evaluatePredicate,
69
71
  fleetContextFromState,
70
- formatPercent,
71
72
  mapNodeLabel,
72
73
  normalizeMapId,
73
74
  normalizeOutcomes,
@@ -114,6 +115,18 @@ const NODE_DEFAULTS = {
114
115
  border: '#999999',
115
116
  }
116
117
 
118
+ const COMBAT_NODE_TYPES = new Set([4, 5, 11, 13, 15])
119
+ const AIR_NODE_TYPES = new Set([7, 10])
120
+ const AIR_STATUS_NODE_TYPES = new Set([4, 5, 7, 10, 11, 13, 15])
121
+ const ICON_ONLY_NODE_TYPES = new Set([0, 5, 7, 8, 10])
122
+ const PROPHET_NODE_ICONS = {
123
+ 5: '5',
124
+ 8: '8',
125
+ }
126
+ const NODE_ICON_SIZE = 64
127
+ const NODE_ICON_SIZE_BY_TYPE = { 7: 56, 8: 48, 10: 56 }
128
+ const NODE_ICON_OFFSET_Y_BY_TYPE = { 0: -2, 5: -6 }
129
+
117
130
  function nodeVisualStyle(nodeInfo = {}) {
118
131
  const style = { ...NODE_DEFAULTS, ...nodeInfo }
119
132
  const size = Number.isFinite(Number(style.size)) ? Number(style.size) : Number(style.simpleSize)
@@ -156,6 +169,66 @@ function getRootState() {
156
169
  return store && typeof store.getState === 'function' ? store.getState() : {}
157
170
  }
158
171
 
172
+ function compassStorage() {
173
+ try {
174
+ return typeof window !== 'undefined' && window.localStorage ? window.localStorage : null
175
+ } catch (_) {
176
+ return null
177
+ }
178
+ }
179
+
180
+ function isRecord(value) {
181
+ return value != null && typeof value === 'object' && !Array.isArray(value)
182
+ }
183
+
184
+ function normalizeOverrides(value) {
185
+ if (!isRecord(value)) return {}
186
+ return Object.fromEntries(Object.entries(value).filter(([node, target]) => node && typeof target === 'string' && target))
187
+ }
188
+
189
+ function readCompassPreferences() {
190
+ const storage = compassStorage()
191
+ if (!storage) return {}
192
+ try {
193
+ const parsed = JSON.parse(storage.getItem(PREFERENCES_STORAGE_KEY) || '{}')
194
+ return isRecord(parsed) ? parsed : {}
195
+ } catch (_) {
196
+ return {}
197
+ }
198
+ }
199
+
200
+ function viewPreferenceKey(mapId, phaseId, deckId) {
201
+ return `${mapId}|${phaseId || ''}|${deckId}`
202
+ }
203
+
204
+ function storedOverridesForView(preferences, mapId, phaseId, deckId) {
205
+ const overridesByView = isRecord(preferences?.overridesByView) ? preferences.overridesByView : {}
206
+ return normalizeOverrides(overridesByView[viewPreferenceKey(mapId, phaseId, deckId)])
207
+ }
208
+
209
+ function normalizeStringMap(value) {
210
+ if (!isRecord(value)) return {}
211
+ return Object.fromEntries(Object.entries(value).filter(([key, entry]) => key && typeof entry === 'string' && entry))
212
+ }
213
+
214
+ function normalizeNumberMap(value) {
215
+ if (!isRecord(value)) return {}
216
+ return Object.fromEntries(Object.entries(value)
217
+ .map(([key, entry]) => [key, Number(entry)])
218
+ .filter(([key, entry]) => key && Number.isFinite(entry)))
219
+ }
220
+
221
+ function storedExportPreferencesForView(preferences, mapId, phaseId, deckId) {
222
+ const exportByView = isRecord(preferences?.exportByView) ? preferences.exportByView : {}
223
+ const stored = exportByView[viewPreferenceKey(mapId, phaseId, deckId)]
224
+ if (!isRecord(stored)) return { exportRoute: null, exportEnemies: {}, exportFormations: {} }
225
+ return {
226
+ exportRoute: typeof stored.route === 'string' && stored.route ? stored.route : null,
227
+ exportEnemies: normalizeStringMap(stored.enemies),
228
+ exportFormations: normalizeNumberMap(stored.formations),
229
+ }
230
+ }
231
+
159
232
  function getFleet(state, deckId = 1) {
160
233
  const fleets = state?.info?.fleets
161
234
  if (Array.isArray(fleets)) return fleets[deckId - 1] || null
@@ -275,6 +348,21 @@ function nodeIconUrl(icon) {
275
348
  return null
276
349
  }
277
350
 
351
+ function prophetSpotIconUrl(icon) {
352
+ if (icon == null) return null
353
+ return pathToFileURL(path.join(LOCAL_PROPHET_SPOT_ICON_ROOT, `${icon}.svg`)).toString()
354
+ }
355
+
356
+ function nodeMarker(nodeType, nodeInfo) {
357
+ const prophetIcon = PROPHET_NODE_ICONS[nodeType]
358
+ if (prophetIcon) return { source: 'prophet', url: prophetSpotIconUrl(prophetIcon) }
359
+ if ([0, 7, 10].includes(nodeType)) {
360
+ const kcnavIcon = nodeIconUrl(nodeInfo?.icon)
361
+ return kcnavIcon ? { source: 'kcnav', url: kcnavIcon } : null
362
+ }
363
+ return null
364
+ }
365
+
278
366
  function localNodeTypesForMap(mapId, mapDefinition) {
279
367
  const nodeTypes = { ...(localKcnavNodeTypes[mapId] || {}) }
280
368
  if (mapDefinition?.start) nodeTypes[mapDefinition.start] = 0
@@ -400,8 +488,71 @@ function routeEdges(geometry) {
400
488
  .filter((edge) => edge.from && edge.to)
401
489
  }
402
490
 
403
- function outcomeText(outcome) {
404
- return `${outcome.to} ${formatPercent(outcome.probability, outcome.estimated)}`
491
+ function formatProbability(value, estimated = false, mode = 'percent') {
492
+ if (!Number.isFinite(Number(value))) return '未知'
493
+ const probability = Math.max(0, Math.min(1, Number(value)))
494
+ const suffix = estimated ? '?' : ''
495
+ if (mode === 'decimal') {
496
+ const rounded = Math.round(probability * 100) / 100
497
+ return `${rounded === 1 ? '1' : rounded.toFixed(2).replace(/^0/, '')}${suffix}`
498
+ }
499
+ return `${Math.round(probability * 100)}%${suffix}`
500
+ }
501
+
502
+ function outcomeText(outcome, mode = 'percent') {
503
+ return `${outcome.to} ${formatProbability(outcome.probability, outcome.estimated, mode)}`
504
+ }
505
+
506
+ // These predicates came from separate list items in the cached KCWiki route
507
+ // tables. Keep the evaluator's compact OR semantics for compatibility, but
508
+ // render each source line separately so the UI does not invent one combined
509
+ // rule where the source did not have one.
510
+ const SEPARATE_SOURCE_LINE_RULE_IDS = new Set([
511
+ '2-5-C-heavy',
512
+ '1-5-F-heavy',
513
+ '1-6-start-heavy', '1-6-M-heavy',
514
+ '2-3-D-special',
515
+ '4-2-C-light-l',
516
+ '6-1-start-heavy3',
517
+ '6-2-C-a', '6-2-D-f', '6-2-E-f',
518
+ '6-4-start-right-lha', '6-4-A-noakitsu-heavy', '6-4-E-special', '6-4-E-g', '6-4-J-l', '6-4-J-i', '6-4-K-h',
519
+ '6-5-start-left', '6-5-C-e', '6-5-E-i', '6-5-I-h',
520
+ '7-1-B-a', '7-1-H-k',
521
+ '7-2-C-d', '7-2-E-g', '7-2-I-j',
522
+ '7-3-C-heavy', '7-3-G-special', '7-3-I-heavy', '7-3-J-m', '7-3-J-p', '7-3-M-n', '7-3-M-o',
523
+ '7-4-start-c', '7-4-start-a', '7-4-C-d', '7-4-J-k', '7-4-J-l', '7-4-M-heavy',
524
+ '7-5-B-c', '7-5-D-e', '7-5-D-fast', '7-5-J-N', '7-5-J-O-heavy', '7-5-P-r',
525
+ ])
526
+
527
+ function visibleRuleLabel(rule) {
528
+ const label = predicateLabel(rule?.predicate)
529
+ return label
530
+ .replace(/([^)]*(?:近似|约|原文|比例未知|分歧系数)[^)]*)/g, '')
531
+ .replace(/\([^)]*(?:approx|about|original|unknown|coefficient)[^)]*\)/gi, '')
532
+ .replace(/,?\s*失败后继续判定/g, '')
533
+ .replace(/:\s*[^,;:]+概率(?:未知|随索敌变化)/g, '')
534
+ .replace(/;\s*(?:近似|约)/g, '')
535
+ .replace(/(原文(?:标记\s*\?|比例未知)[^)]*)/g, '')
536
+ .replace(/(约)/g, '')
537
+ .replace(/\s{2,}/g, ' ')
538
+ .replace(/:\s*$/g, '')
539
+ .trim()
540
+ }
541
+
542
+ function rulePopoverNotes(rule, rawLabel, visibleLabel) {
543
+ const notes = []
544
+ if (rawLabel !== visibleLabel) notes.push(`原文补充:${rawLabel}`)
545
+ if (rule?.continueOnFailure === true) notes.push('本条判定失败后继续检查后续规则')
546
+ if (rule?.confidence === 'approximate') notes.push('该条件或概率来自近似资料')
547
+ if (rule?.confidence === 'unknown' || rule?.outcomes?.some((outcome) => outcome?.probability == null)) {
548
+ notes.push('来源没有给出明确概率;当前按均等概率暂估')
549
+ }
550
+ return notes
551
+ }
552
+
553
+ function displayPredicatesForRule(rule) {
554
+ if (!SEPARATE_SOURCE_LINE_RULE_IDS.has(rule?.id) || rule?.predicate?.kind !== 'any') return [rule?.predicate]
555
+ return rule.predicate.predicates?.length ? rule.predicate.predicates : [rule.predicate]
405
556
  }
406
557
 
407
558
  function conditionIcon(status, active) {
@@ -423,11 +574,16 @@ function ruleRows(mapDefinition, node, context) {
423
574
  return (mapDefinition.rules || [])
424
575
  .filter((rule) => rule.node === node)
425
576
  .sort((left, right) => number(left.priority) - number(right.priority))
426
- .map((rule) => ({
427
- rule,
428
- result: evaluatePredicate(rule.predicate, context),
429
- outcomes: normalizeOutcomes(rule.outcomes, rule),
430
- }))
577
+ .reduce((rows, rule) => {
578
+ displayPredicatesForRule(rule).forEach((predicate, index) => {
579
+ rows.push({
580
+ rule: { ...rule, id: `${rule.id}::${index}`, predicate, sourceRuleId: rule.id },
581
+ result: evaluatePredicate(predicate, context),
582
+ outcomes: normalizeOutcomes(rule.outcomes, rule),
583
+ })
584
+ })
585
+ return rows
586
+ }, [])
431
587
  }
432
588
 
433
589
  function fleetSummary(context) {
@@ -458,12 +614,21 @@ function edgeProbability(evaluation, from, to) {
458
614
  }
459
615
  }
460
616
 
461
- function probabilityTier(value) {
462
- if (!Number.isFinite(Number(value))) return ''
463
- if (Number(value) === 0) return 'is-probability-zero'
464
- if (Number(value) >= 0.75) return 'is-probability-high'
465
- if (Number(value) >= 0.4) return 'is-probability-medium'
466
- return 'is-probability-low'
617
+ function edgeVisualStyle(value) {
618
+ const probability = Number(value)
619
+ if (!Number.isFinite(probability) || probability <= 0) {
620
+ return { color: '#87919a', width: 2.25, opacity: 0.3 }
621
+ }
622
+ const ratio = Math.max(0, Math.min(1, probability))
623
+ if (ratio >= 0.999999) return { color: '#78d99a', width: 7, opacity: 1 }
624
+ const start = [125, 135, 145]
625
+ const end = [120, 217, 154]
626
+ const color = `rgb(${start.map((component, index) => Math.round(component + (end[index] - component) * ratio)).join(', ')})`
627
+ return {
628
+ color,
629
+ width: 2.25 + ratio * 4.75,
630
+ opacity: 0.3 + ratio * 0.7,
631
+ }
467
632
  }
468
633
 
469
634
  function mapBackgroundUrl(mapId) {
@@ -489,19 +654,27 @@ function manualSourceForTarget(evaluation, target) {
489
654
  class Compass extends React.Component {
490
655
  constructor(props) {
491
656
  super(props)
657
+ const preferences = readCompassPreferences()
658
+ const storedMapId = typeof preferences.mapId === 'string' && mapCatalog.maps[preferences.mapId]
659
+ ? preferences.mapId : null
660
+ const initialMapId = storedMapId
661
+ || (pluginState.currentMapId && mapCatalog.maps[pluginState.currentMapId] ? pluginState.currentMapId : mapIds[0])
662
+ const initialDeckId = [1, 2, 3, 4].includes(Number(preferences.deckId)) ? Number(preferences.deckId) : 1
663
+ const preferredPhaseId = preferences.phaseByMap?.[initialMapId] ?? preferences.phaseId
664
+ const initialPhaseId = phaseOptionForMap(initialMapId, preferredPhaseId)?.id || null
665
+ const initialExportPreferences = storedExportPreferencesForView(preferences, initialMapId, initialPhaseId, initialDeckId)
492
666
  this.state = {
493
- deckId: 1,
494
- mapId: pluginState.currentMapId && mapCatalog.maps[pluginState.currentMapId]
495
- ? pluginState.currentMapId
496
- : mapIds[0],
497
- phaseId: phaseOptionsForMap(mapIds[0])[0]?.id || null,
667
+ deckId: initialDeckId,
668
+ mapId: initialMapId,
669
+ phaseId: initialPhaseId,
498
670
  selectedNode: pluginState.currentNode || null,
499
671
  selectedEdge: null,
500
- overrides: {},
672
+ overrides: storedOverridesForView(preferences, initialMapId, initialPhaseId, initialDeckId),
673
+ probabilityFormat: preferences.probabilityFormat === 'decimal' ? 'decimal' : 'percent',
674
+ showEstimatedAir: typeof preferences.showEstimatedAir === 'boolean' ? preferences.showEstimatedAir : true,
675
+ showEnemyAvatars: typeof preferences.showEnemyAvatars === 'boolean' ? preferences.showEnemyAvatars : true,
501
676
  exportOpen: false,
502
- exportRoute: null,
503
- exportEnemies: {},
504
- exportFormations: {},
677
+ ...initialExportPreferences,
505
678
  exportError: null,
506
679
  exportBusy: false,
507
680
  }
@@ -525,6 +698,42 @@ class Compass extends React.Component {
525
698
  this.refresh()
526
699
  }
527
700
 
701
+ componentDidUpdate() {
702
+ this.persistPreferences()
703
+ }
704
+
705
+ persistPreferences() {
706
+ const storage = compassStorage()
707
+ if (!storage) return
708
+ try {
709
+ const previous = readCompassPreferences()
710
+ const overridesByView = isRecord(previous.overridesByView) ? { ...previous.overridesByView } : {}
711
+ overridesByView[viewPreferenceKey(this.state.mapId, this.state.phaseId, this.state.deckId)] = normalizeOverrides(this.state.overrides)
712
+ const exportByView = isRecord(previous.exportByView) ? { ...previous.exportByView } : {}
713
+ exportByView[viewPreferenceKey(this.state.mapId, this.state.phaseId, this.state.deckId)] = {
714
+ route: typeof this.state.exportRoute === 'string' ? this.state.exportRoute : null,
715
+ enemies: normalizeStringMap(this.state.exportEnemies),
716
+ formations: normalizeNumberMap(this.state.exportFormations),
717
+ }
718
+ const phaseByMap = isRecord(previous.phaseByMap) ? { ...previous.phaseByMap } : {}
719
+ phaseByMap[this.state.mapId] = this.state.phaseId
720
+ storage.setItem(PREFERENCES_STORAGE_KEY, JSON.stringify({
721
+ ...previous,
722
+ mapId: this.state.mapId,
723
+ phaseId: this.state.phaseId,
724
+ phaseByMap,
725
+ deckId: this.state.deckId,
726
+ probabilityFormat: this.state.probabilityFormat,
727
+ showEstimatedAir: this.state.showEstimatedAir,
728
+ showEnemyAvatars: this.state.showEnemyAvatars,
729
+ overridesByView,
730
+ exportByView,
731
+ }))
732
+ } catch (_) {
733
+ // Preferences are optional; a restricted localStorage must not break the plugin.
734
+ }
735
+ }
736
+
528
737
  componentWillUnmount() {
529
738
  listeners.delete(this.refresh)
530
739
  this.unsubscribeStore?.()
@@ -532,15 +741,16 @@ class Compass extends React.Component {
532
741
  }
533
742
 
534
743
  selectMap(mapId) {
744
+ const preferences = readCompassPreferences()
745
+ const phaseId = phaseOptionForMap(mapId, preferences.phaseByMap?.[mapId])?.id || null
746
+ const exportPreferences = storedExportPreferencesForView(preferences, mapId, phaseId, this.state.deckId)
535
747
  this.setState({
536
748
  mapId,
537
- phaseId: phaseOptionsForMap(mapId)[0]?.id || null,
749
+ phaseId,
538
750
  selectedNode: null,
539
751
  selectedEdge: null,
540
- overrides: {},
541
- exportRoute: null,
542
- exportEnemies: {},
543
- exportFormations: {},
752
+ overrides: storedOverridesForView(preferences, mapId, phaseId, this.state.deckId),
753
+ ...exportPreferences,
544
754
  exportError: null,
545
755
  })
546
756
  }
@@ -584,7 +794,10 @@ class Compass extends React.Component {
584
794
  }
585
795
 
586
796
  selectPhase(phaseId) {
587
- this.setState({ phaseId, selectedNode: null, selectedEdge: null, overrides: {}, exportRoute: null, exportEnemies: {}, exportFormations: {}, exportError: null })
797
+ const preferences = readCompassPreferences()
798
+ const overrides = storedOverridesForView(preferences, this.state.mapId, phaseId, this.state.deckId)
799
+ const exportPreferences = storedExportPreferencesForView(preferences, this.state.mapId, phaseId, this.state.deckId)
800
+ this.setState({ phaseId, selectedNode: null, selectedEdge: null, overrides, ...exportPreferences, exportError: null })
588
801
  }
589
802
 
590
803
  selectNode(node) {
@@ -592,7 +805,10 @@ class Compass extends React.Component {
592
805
  }
593
806
 
594
807
  selectFleet(deckId) {
595
- this.setState({ deckId, selectedNode: null, selectedEdge: null, overrides: {}, exportRoute: null, exportEnemies: {}, exportFormations: {}, exportError: null })
808
+ const preferences = readCompassPreferences()
809
+ const overrides = storedOverridesForView(preferences, this.state.mapId, this.state.phaseId, deckId)
810
+ const exportPreferences = storedExportPreferencesForView(preferences, this.state.mapId, this.state.phaseId, deckId)
811
+ this.setState({ deckId, selectedNode: null, selectedEdge: null, overrides, ...exportPreferences, exportError: null })
596
812
  }
597
813
 
598
814
  renderLosIcons(scores) {
@@ -613,10 +829,10 @@ class Compass extends React.Component {
613
829
  `制空 ${air.min === air.max ? air.min : `${air.min}~${air.max}`}`)
614
830
  }
615
831
 
616
- setManualOverride(node, to, edgeId = null) {
832
+ setManualOverride(node, to, edgeId = null, selectedNode = node) {
617
833
  this.setState((state) => ({
618
834
  overrides: { ...state.overrides, [node]: to },
619
- selectedNode: node,
835
+ selectedNode,
620
836
  selectedEdge: edgeId,
621
837
  exportRoute: null,
622
838
  exportError: null,
@@ -627,6 +843,35 @@ class Compass extends React.Component {
627
843
  this.setState({ selectedEdge: edgeId })
628
844
  }
629
845
 
846
+ renderNodeEnemyAvatar(ship, rootState, x, y, size) {
847
+ if (!ship?.id) return null
848
+ const name = ship.name || '敌舰'
849
+ return h('foreignObject', {
850
+ className: 'compass-node-overlay compass-node-enemy-avatar',
851
+ x,
852
+ y,
853
+ width: size,
854
+ height: size,
855
+ 'aria-label': `最常见敌方旗舰:${name}`,
856
+ }, h('div', { className: 'compass-node-overlay-content' }, this.renderShipPortrait(ship.id, name, rootState, size)))
857
+ }
858
+
859
+ renderNodeIcon(marker, centerX, centerY, nodeType) {
860
+ if (!marker?.url) return null
861
+ const size = NODE_ICON_SIZE_BY_TYPE[nodeType] || NODE_ICON_SIZE
862
+ const offsetY = NODE_ICON_OFFSET_Y_BY_TYPE[nodeType] || 0
863
+ return h('image', {
864
+ className: `compass-node-icon is-${marker.source}`,
865
+ href: marker.url,
866
+ x: centerX - size / 2,
867
+ y: centerY + offsetY - size / 2,
868
+ width: size,
869
+ height: size,
870
+ preserveAspectRatio: 'xMidYMid meet',
871
+ 'aria-hidden': 'true',
872
+ })
873
+ }
874
+
630
875
  renderMap(mapDefinition, geometry, evaluation, selectedNode, currentNode, passedNodes = [], mapId = null) {
631
876
  if (!mapGeometryAvailable(mapDefinition, geometry)) {
632
877
  return h('div', { className: 'compass-empty-panel' }, 'poi 地图数据不可用')
@@ -636,7 +881,8 @@ class Compass extends React.Component {
636
881
  const edges = routeEdges(geometry)
637
882
  const passedEdges = passedEdgeKeys(passedNodes)
638
883
  const nodeTypes = localNodeTypesForMap(mapId, mapDefinition)
639
- const airPower = calculatePoiFleetStat(getRootState(), this.state.deckId, 1, true)
884
+ const rootState = getRootState()
885
+ const airPower = calculatePoiFleetStat(rootState, this.state.deckId, 1, true)
640
886
  const width = Math.max(1200, ...Object.values(positions).map((position) => position.x + 100))
641
887
  const height = Math.max(720, ...Object.values(positions).map((position) => position.y + 100))
642
888
  return h(
@@ -660,6 +906,7 @@ class Compass extends React.Component {
660
906
  const to = positions[edge.to]
661
907
  if (!from || !to) return null
662
908
  const probability = edgeProbability(evaluation, edge.from, edge.to)
909
+ const edgeVisual = edgeVisualStyle(probability.global)
663
910
  const manualOutcomes = evaluation.decisions[edge.from]?.manualOutcomes
664
911
  || evaluation.decisions[edge.from]?.baseOutcomes
665
912
  || []
@@ -670,7 +917,6 @@ class Compass extends React.Component {
670
917
  'compass-edge',
671
918
  probability.global != null ? 'is-reachable' : '',
672
919
  probability.estimated ? 'is-estimated' : '',
673
- probabilityTier(probability.global),
674
920
  passedEdges.has(`${edge.from}->${edge.to}`) ? 'is-passed' : '',
675
921
  manualChoice ? 'is-manual-choice' : '',
676
922
  selected ? 'is-selected' : '',
@@ -688,6 +934,11 @@ class Compass extends React.Component {
688
934
  role: 'button',
689
935
  tabIndex: 0,
690
936
  'aria-label': `边 ID ${edge.cell}:${edge.from}→${edge.to}`,
937
+ style: {
938
+ '--compass-edge-color': edgeVisual.color,
939
+ '--compass-edge-width': edgeVisual.width,
940
+ '--compass-edge-opacity': edgeVisual.opacity,
941
+ },
691
942
  onClick: () => manualChoice
692
943
  ? this.setManualOverride(edge.from, edge.to, edge.cell)
693
944
  : this.selectEdge(edge.cell),
@@ -697,7 +948,7 @@ class Compass extends React.Component {
697
948
  `边 ID ${edge.cell}:${edge.from} → ${edge.to}`,
698
949
  manualChoice ? '点击选择方向' : null,
699
950
  probability.global != null && Number(probability.global) > 0
700
- ? `局部 ${formatPercent(probability.local, probability.estimated)}`
951
+ ? `局部 ${formatProbability(probability.local, probability.estimated, this.state.probabilityFormat)}`
701
952
  : null,
702
953
  ].filter(Boolean).join(' · ')),
703
954
  h('line', {
@@ -706,13 +957,13 @@ class Compass extends React.Component {
706
957
  x2: to.x,
707
958
  y2: to.y,
708
959
  }),
709
- probability.global != null && Number(probability.global) > 0
960
+ probability.global != null && Number(probability.global) > 0 && Math.round(Number(probability.global) * 100) > 0
710
961
  ? h('text', {
711
962
  x: (from.x + to.x) / 2,
712
963
  y: (from.y + to.y) / 2,
713
964
  dominantBaseline: 'middle',
714
965
  className: 'compass-edge-label',
715
- }, formatPercent(probability.global, probability.estimated))
966
+ }, formatProbability(probability.global, probability.estimated, this.state.probabilityFormat))
716
967
  : null,
717
968
  selected
718
969
  ? h('text', {
@@ -730,10 +981,20 @@ class Compass extends React.Component {
730
981
  ? kcnavNodeCatalog['-1']
731
982
  : kcnavNodeCatalog[String(nodeType)] || kcnavNodeCatalog['-1']
732
983
  const visual = nodeVisualStyle(nodeInfo)
733
- const airState = evaluation.probability.reach[node] > 0 && [4, 5, 7, 10, 15].includes(nodeType)
734
- ? airStateRange(airPower, loadEnemies(mapId, node)) : null
984
+ const marker = nodeMarker(nodeType, nodeInfo)
985
+ const iconOnly = ICON_ONLY_NODE_TYPES.has(nodeType)
986
+ const enemyData = (COMBAT_NODE_TYPES.has(nodeType) || AIR_NODE_TYPES.has(nodeType) || AIR_STATUS_NODE_TYPES.has(nodeType))
987
+ ? loadEnemies(mapId, node) : null
988
+ const commonFlagship = this.state.showEnemyAvatars
989
+ && (COMBAT_NODE_TYPES.has(nodeType) || AIR_NODE_TYPES.has(nodeType))
990
+ && enemyData?.status === 'ready'
991
+ ? enemyData.rows?.[0]?.mainFleet?.[0] : null
992
+ const airState = evaluation.probability.reach[node] > 0 && AIR_STATUS_NODE_TYPES.has(nodeType)
993
+ ? airStateRange(airPower, enemyData) : null
994
+ const displayAirState = this.state.showEstimatedAir ? airState : null
735
995
  const centerX = position.x + visual.offsetX
736
996
  const centerY = position.y + visual.offsetY
997
+ const overlaySize = Math.min(20, Math.max(12, Math.round(Math.min(visual.rx, visual.ry))))
737
998
  const manualSource = manualSourceForTarget(evaluation, node)
738
999
  const manualTarget = manualSource && manualSource !== node
739
1000
  const classNames = [
@@ -747,7 +1008,7 @@ class Compass extends React.Component {
747
1008
  const activateNode = (event) => {
748
1009
  if (event.key !== 'Enter' && event.key !== ' ') return
749
1010
  event.preventDefault()
750
- if (manualTarget) this.setManualOverride(manualSource, node)
1011
+ if (manualTarget) this.setManualOverride(manualSource, node, null, node)
751
1012
  else this.selectNode(node)
752
1013
  }
753
1014
  return h('g', {
@@ -755,11 +1016,11 @@ class Compass extends React.Component {
755
1016
  className: classNames,
756
1017
  role: 'button',
757
1018
  tabIndex: 0,
758
- onClick: () => manualTarget ? this.setManualOverride(manualSource, node) : this.selectNode(node),
1019
+ onClick: () => manualTarget ? this.setManualOverride(manualSource, node, null, node) : this.selectNode(node),
759
1020
  onKeyDown: activateNode,
760
1021
  },
761
1022
  h('title', null, nodeInfo?.label ? `${node} · ${nodeInfo.label}` : node),
762
- h('ellipse', {
1023
+ iconOnly ? null : h('ellipse', {
763
1024
  className: 'compass-node-shape',
764
1025
  cx: centerX,
765
1026
  cy: centerY,
@@ -770,7 +1031,7 @@ class Compass extends React.Component {
770
1031
  strokeWidth: visual.stroke,
771
1032
  opacity: visual.opacity,
772
1033
  }),
773
- visual.simpleBorder
1034
+ !iconOnly && visual.simpleBorder
774
1035
  ? h('ellipse', {
775
1036
  className: 'compass-node-simple-border',
776
1037
  cx: centerX,
@@ -782,9 +1043,10 @@ class Compass extends React.Component {
782
1043
  opacity: visual.opacity,
783
1044
  })
784
1045
  : null,
1046
+ this.renderNodeIcon(marker, centerX, centerY, nodeType),
785
1047
  h('text', {
786
1048
  x: centerX,
787
- y: centerY + visual.textOffsetY,
1049
+ y: centerY + (iconOnly ? 0 : visual.textOffsetY),
788
1050
  dominantBaseline: 'middle',
789
1051
  className: 'compass-node-label',
790
1052
  style: {
@@ -794,11 +1056,32 @@ class Compass extends React.Component {
794
1056
  fontSize: visual.fontSize,
795
1057
  },
796
1058
  }, node),
797
- airState ? h('text', { x: centerX, y: centerY + visual.ry + 21, className: 'compass-node-air-state', textAnchor: 'middle' },
1059
+ commonFlagship ? this.renderNodeEnemyAvatar(
1060
+ commonFlagship,
1061
+ rootState,
1062
+ centerX + visual.rx * 0.7 - overlaySize / 2,
1063
+ centerY + visual.ry * 0.7 - overlaySize / 2,
1064
+ overlaySize,
1065
+ ) : null,
1066
+ displayAirState ? h('text', {
1067
+ x: centerX + visual.rx * 0.85,
1068
+ y: centerY - visual.ry * 0.85,
1069
+ className: 'compass-node-air-state',
1070
+ textAnchor: 'middle',
1071
+ dominantBaseline: 'middle',
1072
+ },
798
1073
  h('title', null, `当前舰队制空 ${airPower.min}~${airPower.max};未计沿途损耗、陆航和支援${airState.uncertain ? ';敌编成数据不完整或不确定' : ''}`),
799
- ...airState.states.flatMap((state, index) => [
1074
+ ...displayAirState.states.flatMap((state, index) => [
800
1075
  index ? h('tspan', { key: 'separator', fill: '#ffffff' }, '/') : null,
801
- h('tspan', { key: state.label, fill: state.color }, state.label),
1076
+ h('tspan', {
1077
+ key: `${state.label}-${index}`,
1078
+ fill: '#ffffff',
1079
+ style: {
1080
+ fill: '#ffffff',
1081
+ textShadow: `1px 1px 0 ${state.shadow}`,
1082
+ filter: `drop-shadow(1px 1px 0 ${state.shadow})`,
1083
+ },
1084
+ }, state.label),
802
1085
  ]), airState.uncertain ? '?' : null) : null,
803
1086
  )
804
1087
  }),
@@ -852,7 +1135,7 @@ class Compass extends React.Component {
852
1135
  route ? h('label', { className: 'compass-export-route' }, '路线',
853
1136
  h('select', { value: route.key, 'aria-label': '导出路线', onChange: (event) => this.setState({ exportRoute: event.target.value, exportError: null }) },
854
1137
  ...choices.routes.map((candidate) => h('option', { key: candidate.key, value: candidate.key },
855
- `${candidate.nodes.join('→')}${candidate.boss ? ' · Boss' : ''} · ${candidate.probability == null ? '含能动选路' : formatPercent(candidate.probability, candidate.estimated)}`))),
1138
+ `${candidate.nodes.join('→')}${candidate.boss ? ' · Boss' : ''} · ${candidate.probability == null ? '含能动选路' : formatProbability(candidate.probability, candidate.estimated, this.state.probabilityFormat)}`))),
856
1139
  ) : null,
857
1140
  choices?.blocked.length ? h('div', { className: 'compass-warnings' }, `未知分歧:${choices.blocked.join('、')}`) : null,
858
1141
  battles?.length ? h('div', { className: 'compass-export-battles' },
@@ -960,22 +1243,33 @@ class Compass extends React.Component {
960
1243
  h('h4', null, `${node} 点`),
961
1244
  rows.length
962
1245
  ? h('div', { className: 'compass-conditions' }, ...rows.map(({ rule, result, outcomes: ruleOutcomes }) => {
963
- const active = decision?.rule?.id === rule.id
964
- const displayedOutcomes = active ? decision.outcomes : ruleOutcomes
965
- const outcomes = displayedOutcomes.length ? displayedOutcomes.map(outcomeText).join(' / ') : '—'
1246
+ const sourceRuleId = rule.sourceRuleId || rule.id
1247
+ const sourceWasMatched = decision?.matchedRuleIds?.includes(sourceRuleId) || decision?.rule?.id === sourceRuleId
1248
+ const splitSourceRule = SEPARATE_SOURCE_LINE_RULE_IDS.has(sourceRuleId)
1249
+ const active = splitSourceRule ? sourceWasMatched && result.status === 'true' : sourceWasMatched
1250
+ const displayedOutcomes = active && (decision?.matchedRuleIds?.length || 0) <= 1
1251
+ ? decision.outcomes
1252
+ : ruleOutcomes
1253
+ const outcomes = displayedOutcomes.length
1254
+ ? displayedOutcomes.map((outcome) => outcomeText(outcome, this.state.probabilityFormat)).join(' / ')
1255
+ : '—'
1256
+ const rawLabel = predicateLabel(rule.predicate)
1257
+ const label = visibleRuleLabel(rule)
1258
+ const notes = rulePopoverNotes(rule, rawLabel, label)
966
1259
  return h('div', {
967
1260
  key: rule.id,
968
1261
  className: `compass-condition is-${result.status}${active ? ' is-active' : ''}`,
969
1262
  tabIndex: 0,
970
- 'aria-label': `${conditionStatus(result.status)}:${predicateLabel(rule.predicate)}`,
1263
+ 'aria-label': `${conditionStatus(result.status)}:${label}`,
971
1264
  },
972
1265
  h('span', { className: 'compass-condition-icon', 'aria-hidden': 'true' }, conditionIcon(result.status, active)),
973
- h('span', { className: 'compass-condition-label' }, predicateLabel(rule.predicate)),
1266
+ h('span', { className: 'compass-condition-label' }, label),
974
1267
  h('span', { className: 'compass-condition-outcomes' }, outcomes),
975
1268
  h('div', { className: 'compass-condition-popover', role: 'tooltip' },
976
1269
  h('div', null, `${conditionStatus(result.status)}${active ? ' · 当前出口规则' : ''}`),
1270
+ h('div', null, `条件:${rawLabel}`),
977
1271
  h('div', null, `出口:${outcomes}`),
978
- rule.confidence === 'approximate' ? h('div', null, '概率为资料中的近似值') : null,
1272
+ ...notes.map((note, index) => h('div', { key: `${rule.id}-note-${index}`, className: 'compass-condition-note' }, note)),
979
1273
  ),
980
1274
  )
981
1275
  }))
@@ -1048,6 +1342,35 @@ class Compass extends React.Component {
1048
1342
  }, option.label)),
1049
1343
  )
1050
1344
  : null,
1345
+ h('div', { className: 'compass-option-group', role: 'group', 'aria-label': '概率标签格式' },
1346
+ h('span', { className: 'compass-option-label' }, '概率'),
1347
+ ...[
1348
+ ['percent', '%'],
1349
+ ['decimal', '小数'],
1350
+ ].map(([value, label]) => h('button', {
1351
+ key: value,
1352
+ type: 'button',
1353
+ className: this.state.probabilityFormat === value ? 'is-active' : '',
1354
+ 'aria-pressed': this.state.probabilityFormat === value,
1355
+ onClick: () => this.setState({ probabilityFormat: value }),
1356
+ }, label)),
1357
+ ),
1358
+ h('label', { className: 'compass-option-toggle' },
1359
+ h('input', {
1360
+ type: 'checkbox',
1361
+ checked: this.state.showEstimatedAir,
1362
+ onChange: (event) => this.setState({ showEstimatedAir: event.target.checked }),
1363
+ }),
1364
+ h('span', null, '推测制空'),
1365
+ ),
1366
+ h('label', { className: 'compass-option-toggle' },
1367
+ h('input', {
1368
+ type: 'checkbox',
1369
+ checked: this.state.showEnemyAvatars,
1370
+ onChange: (event) => this.setState({ showEnemyAvatars: event.target.checked }),
1371
+ }),
1372
+ h('span', null, '敌舰头像'),
1373
+ ),
1051
1374
  h('span', {
1052
1375
  className: 'compass-toolbar-fleet',
1053
1376
  title: `舰队 ${this.state.deckId}:${fleetSummary(context)};${mapLosText}`,
@@ -1086,6 +1409,8 @@ exports.pluginWillUnload = stopPlugin
1086
1409
  exports.__test = {
1087
1410
  calculatePoiLos33,
1088
1411
  cellFromDetail,
1412
+ edgeVisualStyle,
1413
+ formatProbability,
1089
1414
  handleGameResponse,
1090
1415
  kcnavNodeTypesFromMap,
1091
1416
  localNodeTypesForMap,
@@ -1100,4 +1425,7 @@ exports.__test = {
1100
1425
  phaseOptionsForMap,
1101
1426
  pluginState,
1102
1427
  routeEdges,
1428
+ ruleRows,
1429
+ visibleRuleLabel,
1430
+ separateSourceLineRuleIds: SEPARATE_SOURCE_LINE_RULE_IDS,
1103
1431
  }