mirror of
https://github.com/wanderer-industries/wanderer
synced 2025-11-30 04:53:24 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4af12c21b2 | ||
|
|
497da1e5f7 | ||
|
|
5bd968acae | ||
|
|
f74c20142c | ||
|
|
d4c40d7542 | ||
|
|
04f3fec0c0 | ||
|
|
cd0b4b0fc9 | ||
|
|
e7b115e6e6 | ||
|
|
dff8fc6396 | ||
|
|
afdaeb3d34 | ||
|
|
ac6053361e | ||
|
|
eb3e1ba3aa | ||
|
|
8468a9b5de | ||
|
|
5eafe59dcb | ||
|
|
b38bcaa8cf | ||
|
|
8a238a447d | ||
|
|
3731219216 | ||
|
|
73d5fd5f67 | ||
|
|
e8e4aed6d5 | ||
|
|
63571a462f | ||
|
|
606add4142 | ||
|
|
dac480b059 | ||
|
|
5f67cb1dd7 | ||
|
|
5886fff753 | ||
|
|
da2e12bdd1 | ||
|
|
05c3d20e56 | ||
|
|
4633d26517 | ||
|
|
30b0556d47 | ||
|
|
e094378dc5 |
@@ -7,3 +7,4 @@ export EVE_CLIENT_WITH_WALLET_SECRET="<EVE_CLIENT_WITH_WALLET_SECRET>"
|
||||
export GIT_SHA="1111"
|
||||
export WANDERER_INVITES="false"
|
||||
export WANDERER_PUBLIC_API_DISABLED="false"
|
||||
export WANDERER_ZKILL_PRELOAD_DISABLED="false"
|
||||
|
||||
99
CHANGELOG.md
99
CHANGELOG.md
@@ -2,6 +2,105 @@
|
||||
|
||||
<!-- changelog -->
|
||||
|
||||
## [v1.44.3](https://github.com/wanderer-industries/wanderer/compare/v1.44.2...v1.44.3) (2025-02-02)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* restored kills lightning bolt functionality (#143)
|
||||
|
||||
## [v1.44.2](https://github.com/wanderer-industries/wanderer/compare/v1.44.1...v1.44.2) (2025-02-02)
|
||||
|
||||
|
||||
|
||||
|
||||
## [v1.44.1](https://github.com/wanderer-industries/wanderer/compare/v1.44.0...v1.44.1) (2025-02-01)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Map: Fixed problem with windows. (#140)
|
||||
|
||||
* Map: Fixed problem with windows.
|
||||
|
||||
* Core: Added min heigth for body
|
||||
|
||||
## [v1.44.0](https://github.com/wanderer-industries/wanderer/compare/v1.43.9...v1.44.0) (2025-02-01)
|
||||
|
||||
|
||||
|
||||
|
||||
### Features:
|
||||
|
||||
* add news post for zkill widget
|
||||
|
||||
* add zkill widget
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* design feedback patch
|
||||
|
||||
* removed unneeded event handler
|
||||
|
||||
## [v1.43.9](https://github.com/wanderer-industries/wanderer/compare/v1.43.8...v1.43.9) (2025-01-30)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Core: Add discord link to 'Like' icon on main interface
|
||||
|
||||
## [v1.43.8](https://github.com/wanderer-industries/wanderer/compare/v1.43.7...v1.43.8) (2025-01-26)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Core: Update shuttered constellations (required EVE DB data update on server).
|
||||
|
||||
## [v1.43.7](https://github.com/wanderer-industries/wanderer/compare/v1.43.6...v1.43.7) (2025-01-26)
|
||||
|
||||
|
||||
|
||||
|
||||
## [v1.43.6](https://github.com/wanderer-industries/wanderer/compare/v1.43.5...v1.43.6) (2025-01-22)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Widgets: Fix widgets not visible on map
|
||||
|
||||
## [v1.43.5](https://github.com/wanderer-industries/wanderer/compare/v1.43.4...v1.43.5) (2025-01-22)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Audit: Fix signature added/removed system name
|
||||
|
||||
## [v1.43.4](https://github.com/wanderer-industries/wanderer/compare/v1.43.3...v1.43.4) (2025-01-21)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* improve structure widget styling (#127)
|
||||
|
||||
## [v1.43.3](https://github.com/wanderer-industries/wanderer/compare/v1.43.2...v1.43.3) (2025-01-21)
|
||||
|
||||
|
||||
|
||||
|
||||
## [v1.43.2](https://github.com/wanderer-industries/wanderer/compare/v1.43.1...v1.43.2) (2025-01-21)
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ body {
|
||||
width: 400px; /* As IE6 ignores !important it will set width as 400px; */
|
||||
}
|
||||
|
||||
body > div:first-of-type {
|
||||
min-height: 500px !important;
|
||||
}
|
||||
|
||||
.lending-normal {
|
||||
font-family: 'Shentox', 'Rogan', sans-serif !important;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -112,3 +112,28 @@
|
||||
.p-autocomplete .p-autocomplete-multiple-container .p-autocomplete-token {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
/* Fixed sizes of Input switch */
|
||||
.p-inputswitch {
|
||||
width: 2.0rem;
|
||||
height: 1.15rem;
|
||||
|
||||
.p-inputswitch-slider:before {
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
left: 0.14rem;
|
||||
margin-top: -0.385rem;
|
||||
}
|
||||
|
||||
&.p-highlight .p-inputswitch-slider:before {
|
||||
transform: translateX(0.8rem);
|
||||
}
|
||||
|
||||
&:not(.p-disabled):has(.p-inputswitch-input:hover) .p-inputswitch-slider {
|
||||
background: rgb(255 255 255 / 21%);
|
||||
}
|
||||
|
||||
&.p-highlight .p-inputswitch-slider {
|
||||
background: #966d3d;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { OutCommandHandler } from '@/hooks/Mapper/types/mapHandlers.ts';
|
||||
import { MapUnionTypes } from '@/hooks/Mapper/types';
|
||||
import { MapUnionTypes, SystemSignature } from '@/hooks/Mapper/types';
|
||||
import { ContextStoreDataUpdate, useContextStore } from '@/hooks/Mapper/utils';
|
||||
|
||||
export type MapData = MapUnionTypes & {
|
||||
@@ -30,10 +30,13 @@ const INITIAL_DATA: MapData = {
|
||||
isConnecting: false,
|
||||
connections: [],
|
||||
hoverNodeId: null,
|
||||
linkedSigEveId: '',
|
||||
visibleNodes: new Set(),
|
||||
showKSpaceBG: false,
|
||||
isThickConnections: false,
|
||||
userPermissions: {},
|
||||
systemSignatures: {} as Record<string, SystemSignature[]>,
|
||||
options: {} as Record<string, string | boolean>,
|
||||
};
|
||||
|
||||
export interface MapContextProps {
|
||||
|
||||
@@ -15,4 +15,8 @@
|
||||
font-weight: bolder;
|
||||
display: block;
|
||||
}
|
||||
|
||||
& > .Eol {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import { WORMHOLE_CLASS_STYLES, WORMHOLES_ADDITIONAL_INFO } from '@/hooks/Mapper
|
||||
import { useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { renderInfoColumn } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/renders';
|
||||
|
||||
import { k162Types } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureK162TypeSelect';
|
||||
import { K162_TYPES_MAP } from '@/hooks/Mapper/constants.ts';
|
||||
import { parseSignatureCustomInfo } from '@/hooks/Mapper/helpers/parseSignatureCustomInfo.ts';
|
||||
|
||||
interface UnsplashedSignatureProps {
|
||||
signature: SystemSignature;
|
||||
@@ -22,17 +22,22 @@ export const UnsplashedSignature = ({ signature }: UnsplashedSignatureProps) =>
|
||||
const whData = useMemo(() => wormholesData[signature.type], [signature.type, wormholesData]);
|
||||
const whClass = useMemo(() => (whData ? WORMHOLES_ADDITIONAL_INFO[whData.dest] : null), [whData]);
|
||||
|
||||
const k162TypeOption = useMemo(() => {
|
||||
if (!signature.custom_info) {
|
||||
return null;
|
||||
}
|
||||
const customInfo = JSON.parse(signature.custom_info);
|
||||
if (!customInfo.k162Type) {
|
||||
return null;
|
||||
}
|
||||
return k162Types.find(x => x.value === customInfo.k162Type);
|
||||
const customInfo = useMemo(() => {
|
||||
return parseSignatureCustomInfo(signature.custom_info);
|
||||
}, [signature]);
|
||||
|
||||
const k162TypeOption = useMemo(() => {
|
||||
if (!customInfo?.k162Type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return K162_TYPES_MAP[customInfo.k162Type];
|
||||
}, [customInfo]);
|
||||
|
||||
const isEOL = useMemo(() => {
|
||||
return customInfo?.isEOL;
|
||||
}, [customInfo]);
|
||||
|
||||
const whClassStyle = useMemo(() => {
|
||||
if (signature.type === 'K162' && k162TypeOption) {
|
||||
const k162Data = wormholesData[k162TypeOption.whClassName];
|
||||
@@ -45,19 +50,19 @@ export const UnsplashedSignature = ({ signature }: UnsplashedSignatureProps) =>
|
||||
return (
|
||||
<WdTooltipWrapper
|
||||
className={clsx(classes.Signature)}
|
||||
// @ts-ignore
|
||||
content={
|
||||
(
|
||||
<div className="flex flex-col gap-1">
|
||||
<InfoDrawer title={<b className="text-slate-50">{signature.eve_id}</b>}>
|
||||
{renderInfoColumn(signature)}
|
||||
</InfoDrawer>
|
||||
</div>
|
||||
) as React.ReactNode
|
||||
<div className="flex flex-col gap-1">
|
||||
<InfoDrawer title={<b className="text-slate-50">{signature.eve_id}</b>}>
|
||||
{renderInfoColumn(signature)}
|
||||
</InfoDrawer>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={clsx(classes.Box, whClassStyle)}>
|
||||
<svg width="13" height="4" viewBox="0 0 13 4" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="13" height="4" rx="2" className={whClassStyle} fill="currentColor" />
|
||||
<svg width="13" height="8" viewBox="0 0 13 8" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect y="1" width="13" height="4" rx="2" className={whClassStyle} fill="currentColor" />
|
||||
{isEOL && <rect x="4" width="5" height="6" rx="1" className={clsx(classes.Eol)} fill="#a153ac" />}
|
||||
</svg>
|
||||
</div>
|
||||
</WdTooltipWrapper>
|
||||
|
||||
@@ -21,5 +21,12 @@ export const MapInterface = () => {
|
||||
.filter(x => windowsSettings.visible.some(j => x.id === j));
|
||||
}, [windowsSettings]);
|
||||
|
||||
return <WindowManager windows={items} dragSelector=".react-grid-dragHandleExample" onChange={updateWidgetSettings} />;
|
||||
return (
|
||||
<WindowManager
|
||||
windows={items}
|
||||
viewPort={windowsSettings.viewPort}
|
||||
dragSelector=".react-grid-dragHandleExample"
|
||||
onChange={updateWidgetSettings}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
SystemInfo,
|
||||
SystemSignatures,
|
||||
SystemStructures,
|
||||
SystemKills,
|
||||
} from '@/hooks/Mapper/components/mapInterface/widgets';
|
||||
|
||||
export const CURRENT_WINDOWS_VERSION = 8;
|
||||
@@ -16,6 +17,7 @@ export enum WidgetsIds {
|
||||
local = 'local',
|
||||
routes = 'routes',
|
||||
structures = 'structures',
|
||||
kills = 'kills',
|
||||
}
|
||||
|
||||
export const STORED_VISIBLE_WIDGETS_DEFAULT = [
|
||||
@@ -61,6 +63,13 @@ export const DEFAULT_WIDGETS: WindowProps[] = [
|
||||
zIndex: 0,
|
||||
content: () => <SystemStructures />,
|
||||
},
|
||||
{
|
||||
id: WidgetsIds.kills,
|
||||
position: { x: 270, y: 730 },
|
||||
size: { width: 510, height: 200 },
|
||||
zIndex: 0,
|
||||
content: () => <SystemKills />,
|
||||
},
|
||||
];
|
||||
|
||||
type WidgetsCheckboxesType = {
|
||||
@@ -89,4 +98,18 @@ export const WIDGETS_CHECKBOXES_PROPS: WidgetsCheckboxesType = [
|
||||
id: WidgetsIds.structures,
|
||||
label: 'Structures',
|
||||
},
|
||||
{
|
||||
id: WidgetsIds.kills,
|
||||
label: 'Kills',
|
||||
},
|
||||
];
|
||||
|
||||
export function getWidgetsCheckboxesProps(detailedKillsDisabled: boolean): WidgetsCheckboxesType {
|
||||
return filterOutKills(WIDGETS_CHECKBOXES_PROPS, detailedKillsDisabled);
|
||||
}
|
||||
|
||||
|
||||
function filterOutKills<T extends { id: WidgetsIds }>(items: T[], shouldFilter: boolean) {
|
||||
if (!shouldFilter) return items;
|
||||
return items.filter((w) => w.id !== WidgetsIds.kills);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { Widget } from '@/hooks/Mapper/components/mapInterface/components';
|
||||
import { SystemKillsContent } from './SystemKillsContent/SystemKillsContent';
|
||||
import { KillsHeader } from './components/SystemKillsHeader';
|
||||
import { useKillsWidgetSettings } from './hooks/useKillsWidgetSettings';
|
||||
import { useSystemKills } from './hooks/useSystemKills';
|
||||
import { KillsSettingsDialog } from './components/SystemKillsSettingsDialog';
|
||||
|
||||
export const SystemKills: React.FC = () => {
|
||||
const {
|
||||
data: { selectedSystems, systems },
|
||||
outCommand,
|
||||
} = useMapRootState();
|
||||
|
||||
const [systemId] = selectedSystems || [];
|
||||
|
||||
const [settingsDialogVisible, setSettingsDialogVisible] = useState(false);
|
||||
|
||||
const systemNameMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
systems.forEach(sys => {
|
||||
map[sys.id] = sys.temporary_name || sys.name || '???';
|
||||
});
|
||||
return map;
|
||||
}, [systems]);
|
||||
|
||||
const [settings] = useKillsWidgetSettings();
|
||||
const visible = settings.showAll;
|
||||
|
||||
const { kills, isLoading, error } = useSystemKills({
|
||||
systemId,
|
||||
outCommand,
|
||||
showAllVisible: visible,
|
||||
});
|
||||
|
||||
const isNothingSelected = !systemId && !visible;
|
||||
const showLoading = isLoading && kills.length === 0;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0">
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<Widget label={<KillsHeader systemId={systemId} onOpenSettings={() => setSettingsDialogVisible(true)} />}>
|
||||
{isNothingSelected && (
|
||||
<div className="w-full h-full flex justify-center items-center select-none text-center text-stone-400/80 text-sm">
|
||||
No system selected (or toggle “Show all systems”)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNothingSelected && showLoading && (
|
||||
<div className="w-full h-full flex justify-center items-center select-none text-center text-stone-400/80 text-sm">
|
||||
Loading Kills...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNothingSelected && !showLoading && error && (
|
||||
<div className="w-full h-full flex justify-center items-center select-none text-center text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNothingSelected && !showLoading && !error && (!kills || kills.length === 0) && (
|
||||
<div className="w-full h-full flex justify-center items-center select-none text-center text-stone-400/80 text-sm">
|
||||
No kills found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNothingSelected && !showLoading && !error && (
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
<SystemKillsContent
|
||||
key={settings.compact ? 'compact' : 'normal'}
|
||||
kills={kills}
|
||||
systemNameMap={systemNameMap}
|
||||
compact={settings.compact}
|
||||
onlyOneSystem={!visible}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Widget>
|
||||
</div>
|
||||
|
||||
<KillsSettingsDialog visible={settingsDialogVisible} setVisible={setSettingsDialogVisible} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
.TableRowCompact {
|
||||
height: 8px;
|
||||
max-height: 8px;
|
||||
font-size: 12px !important;
|
||||
line-height: 8px;
|
||||
}
|
||||
|
||||
.Table {
|
||||
font-size: 12px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.Tooltip {
|
||||
white-space: pre-line;
|
||||
line-height: 1.2rem;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { DetailedKill } from '@/hooks/Mapper/types/kills';
|
||||
import { KillRow } from '../components/SystemKillsRow';
|
||||
|
||||
interface SystemKillsContentProps {
|
||||
kills: DetailedKill[];
|
||||
systemNameMap: Record<string, string>;
|
||||
compact?: boolean;
|
||||
onlyOneSystem?: boolean;
|
||||
}
|
||||
|
||||
export const SystemKillsContent: React.FC<SystemKillsContentProps> = ({
|
||||
kills,
|
||||
systemNameMap,
|
||||
compact = false,
|
||||
onlyOneSystem = false,
|
||||
}) => {
|
||||
const sortedKills = useMemo(() => {
|
||||
return [...kills].sort((a, b) => {
|
||||
const timeA = a.kill_time ? new Date(a.kill_time).getTime() : 0;
|
||||
const timeB = b.kill_time ? new Date(b.kill_time).getTime() : 0;
|
||||
return timeB - timeA;
|
||||
});
|
||||
}, [kills]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col w-full text-stone-200 text-xs transition-all duration-300',
|
||||
compact ? 'p-1' : 'p-1',
|
||||
)}
|
||||
>
|
||||
{sortedKills.map(kill => {
|
||||
const systemIdStr = String(kill.solar_system_id);
|
||||
const systemName = systemNameMap[systemIdStr] || `System ${systemIdStr}`;
|
||||
|
||||
return (
|
||||
<KillRow
|
||||
key={kill.killmail_id}
|
||||
killDetails={kill}
|
||||
systemName={systemName}
|
||||
isCompact={compact}
|
||||
onlyOneSystem={onlyOneSystem}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { zkillLink } from '../helpers';
|
||||
import classes from './SystemKillRow.module.scss';
|
||||
|
||||
interface AttackerRowSubInfoProps {
|
||||
finalBlowCharId: number | null | undefined;
|
||||
finalBlowCharName?: string;
|
||||
attackerPortraitUrl: string | null;
|
||||
|
||||
finalBlowCorpId: number | null | undefined;
|
||||
finalBlowCorpName?: string;
|
||||
attackerCorpLogoUrl: string | null;
|
||||
|
||||
finalBlowAllianceId: number | null | undefined;
|
||||
finalBlowAllianceName?: string;
|
||||
attackerAllianceLogoUrl: string | null;
|
||||
|
||||
containerHeight?: number;
|
||||
}
|
||||
|
||||
export const AttackerRowSubInfo: React.FC<AttackerRowSubInfoProps> = ({
|
||||
finalBlowCharId = 0,
|
||||
finalBlowCharName,
|
||||
attackerPortraitUrl,
|
||||
containerHeight = 8,
|
||||
}) => {
|
||||
if (!attackerPortraitUrl || finalBlowCharId === null || finalBlowCharId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const containerClass = `h-${containerHeight}`;
|
||||
|
||||
return (
|
||||
<div className={clsx('flex items-start gap-1', containerClass)}>
|
||||
<div className="relative shrink-0 w-auto h-full overflow-hidden">
|
||||
<a
|
||||
href={zkillLink('character', finalBlowCharId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block h-full"
|
||||
>
|
||||
<img
|
||||
src={attackerPortraitUrl}
|
||||
alt={finalBlowCharName || 'AttackerPortrait'}
|
||||
className={clsx(classes.killRowImage, 'h-full w-auto object-contain')}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { DetailedKill } from '@/hooks/Mapper/types/kills';
|
||||
import {
|
||||
formatISK,
|
||||
formatTimeMixed,
|
||||
zkillLink,
|
||||
getAttackerSubscript,
|
||||
buildVictimImageUrls,
|
||||
buildAttackerImageUrls,
|
||||
getPrimaryLogoAndTooltip,
|
||||
getAttackerPrimaryImageAndTooltip,
|
||||
} from '../helpers';
|
||||
import { WdTooltipWrapper } from '../../../../ui-kit/WdTooltipWrapper';
|
||||
import classes from './SystemKillRow.module.scss';
|
||||
import { TooltipPosition } from '@/hooks/Mapper/components/ui-kit';
|
||||
|
||||
export interface CompactKillRowProps {
|
||||
killDetails: DetailedKill;
|
||||
systemName: string;
|
||||
onlyOneSystem: boolean;
|
||||
}
|
||||
|
||||
export const CompactKillRow: React.FC<CompactKillRowProps> = ({ killDetails, systemName, onlyOneSystem }) => {
|
||||
const {
|
||||
killmail_id = 0,
|
||||
|
||||
victim_char_name = 'Unknown Pilot',
|
||||
victim_alliance_ticker = '',
|
||||
victim_corp_ticker = '',
|
||||
victim_ship_name = 'Unknown Ship',
|
||||
victim_corp_name = '',
|
||||
victim_alliance_name = '',
|
||||
victim_char_id = 0,
|
||||
victim_corp_id = 0,
|
||||
victim_alliance_id = 0,
|
||||
victim_ship_type_id = 0,
|
||||
|
||||
final_blow_char_id = 0,
|
||||
final_blow_char_name = '',
|
||||
final_blow_alliance_ticker = '',
|
||||
final_blow_alliance_name = '',
|
||||
final_blow_alliance_id = 0,
|
||||
final_blow_corp_ticker = '',
|
||||
final_blow_corp_id = 0,
|
||||
final_blow_corp_name = '',
|
||||
final_blow_ship_type_id = 0,
|
||||
|
||||
kill_time = '',
|
||||
total_value = 0,
|
||||
} = killDetails || {};
|
||||
|
||||
const attackerIsNpc = final_blow_char_id === 0;
|
||||
const victimAffiliationTicker = victim_alliance_ticker || victim_corp_ticker || 'No Ticker';
|
||||
const killValueFormatted = total_value != null && total_value > 0 ? `${formatISK(total_value)} ISK` : null;
|
||||
const attackerName = attackerIsNpc ? '' : final_blow_char_name;
|
||||
const attackerTicker = attackerIsNpc ? '' : final_blow_alliance_ticker || final_blow_corp_ticker || '';
|
||||
const killTimeAgo = kill_time ? formatTimeMixed(kill_time) : '0h ago';
|
||||
const attackerSubscript = getAttackerSubscript(killDetails);
|
||||
|
||||
const { victimCorpLogoUrl, victimAllianceLogoUrl } = buildVictimImageUrls({
|
||||
victim_char_id,
|
||||
victim_ship_type_id,
|
||||
victim_corp_id,
|
||||
victim_alliance_id,
|
||||
});
|
||||
const { attackerCorpLogoUrl, attackerAllianceLogoUrl } = buildAttackerImageUrls({
|
||||
final_blow_char_id,
|
||||
final_blow_corp_id,
|
||||
final_blow_alliance_id,
|
||||
});
|
||||
|
||||
const { url: victimPrimaryLogoUrl, tooltip: victimPrimaryTooltip } = getPrimaryLogoAndTooltip(
|
||||
victimAllianceLogoUrl,
|
||||
victimCorpLogoUrl,
|
||||
victim_alliance_name,
|
||||
victim_corp_name,
|
||||
'Victim',
|
||||
);
|
||||
|
||||
const { url: attackerPrimaryImageUrl, tooltip: attackerPrimaryTooltip } = getAttackerPrimaryImageAndTooltip(
|
||||
attackerIsNpc,
|
||||
attackerAllianceLogoUrl,
|
||||
attackerCorpLogoUrl,
|
||||
final_blow_alliance_name,
|
||||
final_blow_corp_name,
|
||||
final_blow_ship_type_id || 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'h-10 flex items-center border-b border-stone-800',
|
||||
'text-xs whitespace-nowrap overflow-hidden leading-none',
|
||||
)}
|
||||
>
|
||||
{victimPrimaryLogoUrl && (
|
||||
<WdTooltipWrapper content={victimPrimaryTooltip} position={TooltipPosition.top}>
|
||||
<a
|
||||
href={zkillLink('kill', killmail_id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative shrink-0 w-8 h-8 overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={victimPrimaryLogoUrl}
|
||||
alt="VictimPrimaryLogo"
|
||||
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
|
||||
/>
|
||||
</a>
|
||||
</WdTooltipWrapper>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col ml-2 min-w-0 overflow-hidden leading-[1rem]">
|
||||
<div className="truncate text-stone-200">
|
||||
{victim_char_name}
|
||||
<span className="text-stone-400"> / {victimAffiliationTicker}</span>
|
||||
</div>
|
||||
<div className="truncate text-stone-300">
|
||||
{victim_ship_name}
|
||||
{killValueFormatted && (
|
||||
<>
|
||||
<span className="ml-1 text-stone-400">/</span>
|
||||
<span className="ml-1 text-green-400">{killValueFormatted}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center ml-auto gap-2">
|
||||
<div className="flex flex-col items-end min-w-0 overflow-hidden text-right leading-[1rem]">
|
||||
{!attackerIsNpc && (attackerName || attackerTicker) && (
|
||||
<div className="truncate text-stone-200">
|
||||
{attackerName}
|
||||
{attackerTicker && <span className="ml-1 text-stone-400">/ {attackerTicker}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="truncate text-stone-400">
|
||||
{!onlyOneSystem && systemName ? (
|
||||
<>
|
||||
{systemName} / <span className="ml-1 text-red-400">{killTimeAgo}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-red-400">{killTimeAgo}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{attackerPrimaryImageUrl && (
|
||||
<WdTooltipWrapper content={attackerPrimaryTooltip} position={TooltipPosition.top}>
|
||||
<a
|
||||
href={zkillLink('kill', killmail_id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative shrink-0 w-8 h-8 overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={attackerPrimaryImageUrl}
|
||||
alt={attackerIsNpc ? 'NpcShip' : 'AttackerPrimaryLogo'}
|
||||
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
|
||||
/>
|
||||
{attackerSubscript && (
|
||||
<span
|
||||
className={clsx(
|
||||
classes.attackerCountLabel,
|
||||
attackerSubscript.cssClass,
|
||||
'text-[0.6rem] leading-none px-[2px]',
|
||||
)}
|
||||
style={{ bottom: 0, right: 0 }}
|
||||
>
|
||||
{attackerSubscript.label}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</WdTooltipWrapper>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
// FullKillRow.tsx
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { DetailedKill } from '@/hooks/Mapper/types/kills';
|
||||
import {
|
||||
formatISK,
|
||||
formatTimeMixed,
|
||||
zkillLink,
|
||||
getAttackerSubscript,
|
||||
buildVictimImageUrls,
|
||||
buildAttackerImageUrls,
|
||||
getPrimaryLogoAndTooltip,
|
||||
getAttackerPrimaryImageAndTooltip,
|
||||
} from '../helpers';
|
||||
import { VictimRowSubInfo } from './VictimRowSubInfo';
|
||||
import { WdTooltipWrapper } from '../../../../ui-kit/WdTooltipWrapper';
|
||||
import classes from './SystemKillRow.module.scss';
|
||||
import { TooltipPosition } from '@/hooks/Mapper/components/ui-kit';
|
||||
|
||||
export interface FullKillRowProps {
|
||||
killDetails: DetailedKill;
|
||||
systemName: string;
|
||||
onlyOneSystem: boolean;
|
||||
}
|
||||
|
||||
export const FullKillRow: React.FC<FullKillRowProps> = ({ killDetails, systemName, onlyOneSystem }) => {
|
||||
const {
|
||||
killmail_id = 0,
|
||||
|
||||
victim_char_name = '',
|
||||
victim_alliance_ticker = '',
|
||||
victim_corp_ticker = '',
|
||||
victim_ship_name = '',
|
||||
victim_char_id = 0,
|
||||
victim_corp_id = 0,
|
||||
victim_alliance_id = 0,
|
||||
victim_ship_type_id = 0,
|
||||
victim_corp_name = '',
|
||||
victim_alliance_name = '',
|
||||
|
||||
final_blow_char_id = 0,
|
||||
final_blow_char_name = '',
|
||||
final_blow_alliance_ticker = '',
|
||||
final_blow_corp_ticker = '',
|
||||
final_blow_corp_name = '',
|
||||
final_blow_alliance_name = '',
|
||||
final_blow_corp_id = 0,
|
||||
final_blow_alliance_id = 0,
|
||||
final_blow_ship_name = '',
|
||||
final_blow_ship_type_id = 0,
|
||||
|
||||
total_value = 0,
|
||||
kill_time = '',
|
||||
} = killDetails || {};
|
||||
|
||||
const attackerIsNpc = final_blow_char_id === 0;
|
||||
const victimAffiliation = victim_alliance_ticker || victim_corp_ticker;
|
||||
const attackerAffiliation = attackerIsNpc ? '' : final_blow_alliance_ticker || final_blow_corp_ticker || '';
|
||||
|
||||
const killValueFormatted = total_value !== null && total_value > 0 ? `${formatISK(total_value)} ISK` : null;
|
||||
const killTimeAgo = kill_time ? formatTimeMixed(kill_time) : '0h ago';
|
||||
|
||||
const { victimPortraitUrl, victimCorpLogoUrl, victimAllianceLogoUrl } = buildVictimImageUrls({
|
||||
victim_char_id,
|
||||
victim_ship_type_id,
|
||||
victim_corp_id,
|
||||
victim_alliance_id,
|
||||
});
|
||||
const { attackerPortraitUrl, attackerCorpLogoUrl, attackerAllianceLogoUrl } = buildAttackerImageUrls({
|
||||
final_blow_char_id,
|
||||
final_blow_corp_id,
|
||||
final_blow_alliance_id,
|
||||
});
|
||||
|
||||
const { url: victimPrimaryImageUrl, tooltip: victimPrimaryTooltip } = getPrimaryLogoAndTooltip(
|
||||
victimAllianceLogoUrl,
|
||||
victimCorpLogoUrl,
|
||||
victim_alliance_name,
|
||||
victim_corp_name,
|
||||
'Victim',
|
||||
);
|
||||
|
||||
const { url: attackerPrimaryImageUrl, tooltip: attackerPrimaryTooltip } = getAttackerPrimaryImageAndTooltip(
|
||||
attackerIsNpc,
|
||||
attackerAllianceLogoUrl,
|
||||
attackerCorpLogoUrl,
|
||||
final_blow_alliance_name,
|
||||
final_blow_corp_name,
|
||||
final_blow_ship_type_id || 0,
|
||||
);
|
||||
|
||||
const attackerSubscript = getAttackerSubscript(killDetails);
|
||||
|
||||
return (
|
||||
<div className={clsx(classes.killRowContainer, 'h-18 w-full justify-between items-start text-sm py-[4px]')}>
|
||||
{/* ---------------- Victim Side ---------------- */}
|
||||
<div className="flex items-start gap-1 min-w-0 h-full">
|
||||
{/* Victim top-level logo (corp or alliance), with tooltip */}
|
||||
{victimPrimaryImageUrl && (
|
||||
<WdTooltipWrapper content={victimPrimaryTooltip} position={TooltipPosition.top}>
|
||||
<div className="relative shrink-0 w-14 h-14 overflow-hidden">
|
||||
<a
|
||||
href={zkillLink('kill', killmail_id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={victimPrimaryImageUrl}
|
||||
alt="VictimPrimaryLogo"
|
||||
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</WdTooltipWrapper>
|
||||
)}
|
||||
<VictimRowSubInfo
|
||||
victimCharName={victim_char_name}
|
||||
victimCharacterId={victim_char_id}
|
||||
victimPortraitUrl={victimPortraitUrl}
|
||||
/>
|
||||
<div className="flex flex-col text-stone-200 leading-4 min-w-0 overflow-hidden">
|
||||
<div className="truncate">
|
||||
<span className="font-semibold">{victim_char_name}</span>
|
||||
{victimAffiliation && <span className="ml-1 text-stone-400">/ {victimAffiliation}</span>}
|
||||
</div>
|
||||
<div className="truncate text-stone-300">
|
||||
{victim_ship_name}
|
||||
{killValueFormatted && (
|
||||
<>
|
||||
<span className="ml-1 text-stone-400">/</span>
|
||||
<span className="ml-1 text-green-400">{killValueFormatted}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-stone-400">{!onlyOneSystem && systemName && <span>{systemName}</span>}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-1 min-w-0 h-full">
|
||||
<div className="flex flex-col items-end leading-4 min-w-0 overflow-hidden text-right">
|
||||
{!attackerIsNpc && (
|
||||
<div className="truncate font-semibold">
|
||||
{final_blow_char_name}
|
||||
{attackerAffiliation && <span className="ml-1 text-stone-400">/ {attackerAffiliation}</span>}
|
||||
</div>
|
||||
)}
|
||||
{!attackerIsNpc && final_blow_ship_name && (
|
||||
<div className="truncate text-stone-300">{final_blow_ship_name}</div>
|
||||
)}
|
||||
<div className="truncate text-red-400">{killTimeAgo}</div>
|
||||
</div>
|
||||
|
||||
{!attackerIsNpc && attackerPortraitUrl && final_blow_char_id !== null && final_blow_char_id > 0 && (
|
||||
<div className="relative shrink-0 w-14 h-14 overflow-hidden">
|
||||
<a
|
||||
href={zkillLink('character', final_blow_char_id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={attackerPortraitUrl}
|
||||
alt="AttackerPortrait"
|
||||
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attackerPrimaryImageUrl && (
|
||||
<WdTooltipWrapper content={attackerPrimaryTooltip} position={TooltipPosition.top}>
|
||||
<div className="relative shrink-0 w-14 h-14 overflow-hidden">
|
||||
<a
|
||||
href={zkillLink('kill', killmail_id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={attackerPrimaryImageUrl}
|
||||
alt={attackerIsNpc ? 'NpcShip' : 'AttackerPrimaryLogo'}
|
||||
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
|
||||
/>
|
||||
{attackerSubscript && (
|
||||
<span className={clsx(attackerSubscript.cssClass, classes.attackerCountLabel)}>
|
||||
{attackerSubscript.label}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</WdTooltipWrapper>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
.killRowContainer {
|
||||
@apply flex items-center whitespace-nowrap overflow-hidden;
|
||||
&:not(:last-child) {
|
||||
@apply border-b border-stone-800;
|
||||
}
|
||||
@apply bg-transparent transition-all hover:bg-stone-900 hover:border-stone-700;
|
||||
}
|
||||
|
||||
.killRowImage {
|
||||
@apply border border-stone-800 rounded-[4px] object-contain;
|
||||
}
|
||||
|
||||
.attackerCountLabel {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
font-size: 10px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.attackerCountLabelCompact {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
font-size: 0.6rem;
|
||||
line-height: 1;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
padding: 1px 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
LayoutEventBlocker,
|
||||
WdCheckbox,
|
||||
WdImgButton,
|
||||
TooltipPosition,
|
||||
SystemView,
|
||||
} from '@/hooks/Mapper/components/ui-kit';
|
||||
import { useKillsWidgetSettings } from '../hooks/useKillsWidgetSettings';
|
||||
import { PrimeIcons } from 'primereact/api';
|
||||
|
||||
interface KillsWidgetHeaderProps {
|
||||
systemId?: string;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
export const KillsHeader: React.FC<KillsWidgetHeaderProps> = ({ systemId, onOpenSettings }) => {
|
||||
const [settings, setSettings] = useKillsWidgetSettings();
|
||||
const { showAll } = settings;
|
||||
|
||||
const onToggleShowAllVisible = () => {
|
||||
setSettings(prev => ({ ...prev, showAll: !prev.showAll }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center text-xs w-full">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="text-stone-400">
|
||||
Kills
|
||||
{systemId && !showAll && ' in '}
|
||||
</div>
|
||||
{systemId && !showAll && <SystemView systemId={systemId} className="select-none text-center" hideRegion />}
|
||||
</div>
|
||||
|
||||
<LayoutEventBlocker className="flex gap-2 items-center">
|
||||
<WdCheckbox
|
||||
size="xs"
|
||||
labelSide="left"
|
||||
label="Show all systems"
|
||||
value={showAll}
|
||||
classNameLabel="text-stone-400 hover:text-stone-200 transition duration-300"
|
||||
onChange={onToggleShowAllVisible}
|
||||
/>
|
||||
|
||||
<WdImgButton
|
||||
className={PrimeIcons.SLIDERS_H}
|
||||
onClick={onOpenSettings}
|
||||
tooltip={{
|
||||
content: 'Open Kills Settings',
|
||||
position: TooltipPosition.left,
|
||||
}}
|
||||
/>
|
||||
</LayoutEventBlocker>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { DetailedKill } from '@/hooks/Mapper/types/kills';
|
||||
import { CompactKillRow } from './CompactKillRow';
|
||||
import { FullKillRow } from './FullKillRow';
|
||||
|
||||
export interface KillRowProps {
|
||||
killDetails: DetailedKill;
|
||||
systemName: string;
|
||||
isCompact?: boolean;
|
||||
onlyOneSystem?: boolean;
|
||||
}
|
||||
|
||||
export const KillRow: React.FC<KillRowProps> = ({
|
||||
killDetails,
|
||||
systemName,
|
||||
isCompact = false,
|
||||
onlyOneSystem = false,
|
||||
}) => {
|
||||
if (isCompact) {
|
||||
return <CompactKillRow killDetails={killDetails} systemName={systemName} onlyOneSystem={onlyOneSystem} />;
|
||||
}
|
||||
|
||||
return <FullKillRow killDetails={killDetails} systemName={systemName} onlyOneSystem={onlyOneSystem} />;
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Button } from 'primereact/button';
|
||||
import { WdImgButton, SystemView, TooltipPosition } from '@/hooks/Mapper/components/ui-kit';
|
||||
import { PrimeIcons } from 'primereact/api';
|
||||
import { useKillsWidgetSettings } from '../hooks/useKillsWidgetSettings';
|
||||
import {
|
||||
AddSystemDialog,
|
||||
SearchOnSubmitCallback,
|
||||
} from '@/hooks/Mapper/components/mapInterface/components/AddSystemDialog';
|
||||
|
||||
interface KillsSettingsDialogProps {
|
||||
visible: boolean;
|
||||
setVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
export const KillsSettingsDialog: React.FC<KillsSettingsDialogProps> = ({ visible, setVisible }) => {
|
||||
const [globalSettings, setGlobalSettings] = useKillsWidgetSettings();
|
||||
const localRef = useRef({
|
||||
compact: globalSettings.compact,
|
||||
showAll: globalSettings.showAll,
|
||||
excludedSystems: globalSettings.excludedSystems || [],
|
||||
});
|
||||
|
||||
const [, forceRender] = useState(0);
|
||||
|
||||
const [addSystemDialogVisible, setAddSystemDialogVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
localRef.current = {
|
||||
compact: globalSettings.compact,
|
||||
showAll: globalSettings.showAll,
|
||||
excludedSystems: globalSettings.excludedSystems || [],
|
||||
};
|
||||
forceRender(n => n + 1);
|
||||
}
|
||||
}, [visible, globalSettings]);
|
||||
|
||||
const handleCompactChange = useCallback((checked: boolean) => {
|
||||
localRef.current = {
|
||||
...localRef.current,
|
||||
compact: checked,
|
||||
};
|
||||
forceRender(n => n + 1);
|
||||
}, []);
|
||||
|
||||
const handleRemoveSystem = useCallback((sysId: number) => {
|
||||
localRef.current = {
|
||||
...localRef.current,
|
||||
excludedSystems: localRef.current.excludedSystems.filter(id => id !== sysId),
|
||||
};
|
||||
forceRender(n => n + 1);
|
||||
}, []);
|
||||
|
||||
const handleAddSystemSubmit: SearchOnSubmitCallback = useCallback(item => {
|
||||
if (localRef.current.excludedSystems.includes(item.value)) {
|
||||
return;
|
||||
}
|
||||
localRef.current = {
|
||||
...localRef.current,
|
||||
excludedSystems: [...localRef.current.excludedSystems, item.value],
|
||||
};
|
||||
forceRender(n => n + 1);
|
||||
}, []);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
setGlobalSettings(prev => ({
|
||||
...prev,
|
||||
...localRef.current,
|
||||
}));
|
||||
setVisible(false);
|
||||
}, [setGlobalSettings, setVisible]);
|
||||
|
||||
const handleHide = useCallback(() => {
|
||||
setVisible(false);
|
||||
}, [setVisible]);
|
||||
|
||||
const localData = localRef.current;
|
||||
const excluded = localData.excludedSystems || [];
|
||||
|
||||
return (
|
||||
<Dialog header="Kills Settings" visible={visible} style={{ width: '440px' }} draggable={false} onHide={handleHide}>
|
||||
<div className="flex flex-col gap-3 p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="kills-compact-mode"
|
||||
checked={localData.compact}
|
||||
onChange={e => handleCompactChange(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="kills-compact-mode" className="cursor-pointer">
|
||||
Use compact mode
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-stone-400">Excluded Systems</label>
|
||||
<WdImgButton
|
||||
className={PrimeIcons.PLUS_CIRCLE}
|
||||
onClick={() => setAddSystemDialogVisible(true)}
|
||||
tooltip={{ content: 'Add system to excluded list' }}
|
||||
/>
|
||||
</div>
|
||||
{excluded.length === 0 && <div className="text-stone-500 text-xs italic">No systems excluded.</div>}
|
||||
{excluded.map(sysId => (
|
||||
<div key={sysId} className="flex items-center justify-between border-b border-stone-600 py-1 px-1 text-xs">
|
||||
<SystemView systemId={sysId.toString()} hideRegion compact />
|
||||
|
||||
<WdImgButton
|
||||
className={PrimeIcons.TRASH}
|
||||
onClick={() => handleRemoveSystem(sysId)}
|
||||
tooltip={{ content: 'Remove from excluded', position: TooltipPosition.top }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Apply + Close button row */}
|
||||
<div className="flex gap-2 justify-end mt-4">
|
||||
<Button onClick={handleApply} label="Apply" outlined size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AddSystemDialog for picking new systems to exclude */}
|
||||
<AddSystemDialog
|
||||
title="Add system to kills exclude list"
|
||||
visible={addSystemDialogVisible}
|
||||
setVisible={() => setAddSystemDialogVisible(false)}
|
||||
onSubmit={handleAddSystemSubmit}
|
||||
excludedSystems={excluded}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
// VictimSubRowInfo.tsx
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { zkillLink } from '../helpers';
|
||||
import classes from './SystemKillRow.module.scss';
|
||||
|
||||
interface VictimRowSubInfoProps {
|
||||
victimCharacterId: number | null;
|
||||
victimPortraitUrl: string | null;
|
||||
victimCharName?: string;
|
||||
}
|
||||
|
||||
export const VictimRowSubInfo: React.FC<VictimRowSubInfoProps> = ({
|
||||
victimCharacterId = 0,
|
||||
victimPortraitUrl,
|
||||
victimCharName,
|
||||
}) => {
|
||||
if (!victimPortraitUrl || victimCharacterId === null || victimCharacterId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-1 h-14">
|
||||
<div className="relative shrink-0 w-14 h-14 overflow-hidden">
|
||||
<a
|
||||
href={zkillLink('character', victimCharacterId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={victimPortraitUrl}
|
||||
alt={victimCharName || 'Victim Portrait'}
|
||||
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './linkHelpers';
|
||||
export * from './killRowUtils';
|
||||
@@ -0,0 +1,47 @@
|
||||
import { DetailedKill } from '@/hooks/Mapper/types/kills';
|
||||
|
||||
/** Returns "5m ago", "3h ago", "2.5d ago", etc. */
|
||||
export function formatTimeMixed(killTime: string): string {
|
||||
const killDate = new Date(killTime);
|
||||
const diffMs = Date.now() - killDate.getTime();
|
||||
const diffHours = diffMs / (1000 * 60 * 60);
|
||||
|
||||
if (diffHours < 1) {
|
||||
const mins = Math.round(diffHours * 60);
|
||||
return `${mins}m ago`;
|
||||
} else if (diffHours < 24) {
|
||||
const hours = Math.round(diffHours);
|
||||
return `${hours}h ago`;
|
||||
} else {
|
||||
const days = diffHours / 24;
|
||||
const roundedDays = days.toFixed(1);
|
||||
return `${roundedDays}d ago`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats integer ISK values into k/M/B/T. */
|
||||
export function formatISK(value: number): string {
|
||||
if (value >= 1_000_000_000_000) {
|
||||
return `${(value / 1_000_000_000_000).toFixed(2)}T`;
|
||||
} else if (value >= 1_000_000_000) {
|
||||
return `${(value / 1_000_000_000).toFixed(2)}B`;
|
||||
} else if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(2)}M`;
|
||||
} else if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(2)}k`;
|
||||
}
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
|
||||
export function getAttackerSubscript(kill: DetailedKill) {
|
||||
if (kill.npc) {
|
||||
return { label: 'npc', cssClass: 'text-purple-400' };
|
||||
}
|
||||
const count = kill.attacker_count ?? 0;
|
||||
if (count === 1) {
|
||||
return { label: 'solo', cssClass: 'text-green-400' };
|
||||
} else if (count > 1) {
|
||||
return { label: String(count), cssClass: 'text-white' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
const ZKILL_URL = 'https://zkillboard.com';
|
||||
const BASE_IMAGE_URL = 'https://images.evetech.net';
|
||||
|
||||
export function zkillLink(type: 'kill' | 'character' | 'corporation' | 'alliance', id?: number | null): string {
|
||||
if (!id) return `${ZKILL_URL}`;
|
||||
if (type === 'kill') return `${ZKILL_URL}/kill/${id}/`;
|
||||
if (type === 'character') return `${ZKILL_URL}/character/${id}/`;
|
||||
if (type === 'corporation') return `${ZKILL_URL}/corporation/${id}/`;
|
||||
if (type === 'alliance') return `${ZKILL_URL}/alliance/${id}/`;
|
||||
return `${ZKILL_URL}`;
|
||||
}
|
||||
|
||||
export function eveImageUrl(
|
||||
category: 'characters' | 'corporations' | 'alliances' | 'types',
|
||||
id?: number | null,
|
||||
variation: string = 'icon',
|
||||
size?: number,
|
||||
): string | null {
|
||||
if (!id || id <= 0) {
|
||||
return null;
|
||||
}
|
||||
let url = `${BASE_IMAGE_URL}/${category}/${id}/${variation}`;
|
||||
if (size) {
|
||||
url += `?size=${size}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function buildVictimImageUrls(args: {
|
||||
victim_char_id?: number | null;
|
||||
victim_ship_type_id?: number | null;
|
||||
victim_corp_id?: number | null;
|
||||
victim_alliance_id?: number | null;
|
||||
}) {
|
||||
const { victim_char_id, victim_ship_type_id, victim_corp_id, victim_alliance_id } = args;
|
||||
|
||||
const victimPortraitUrl = eveImageUrl('characters', victim_char_id, 'portrait', 64);
|
||||
const victimShipUrl = eveImageUrl('types', victim_ship_type_id, 'render', 64);
|
||||
const victimCorpLogoUrl = eveImageUrl('corporations', victim_corp_id, 'logo', 32);
|
||||
const victimAllianceLogoUrl = eveImageUrl('alliances', victim_alliance_id, 'logo', 32);
|
||||
|
||||
return {
|
||||
victimPortraitUrl,
|
||||
victimShipUrl,
|
||||
victimCorpLogoUrl,
|
||||
victimAllianceLogoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAttackerShipUrl(final_blow_ship_type_id?: number | null): string | null {
|
||||
return eveImageUrl('types', final_blow_ship_type_id, 'render', 64);
|
||||
}
|
||||
|
||||
export function buildAttackerImageUrls(args: {
|
||||
final_blow_char_id?: number | null;
|
||||
final_blow_corp_id?: number | null;
|
||||
final_blow_alliance_id?: number | null;
|
||||
}) {
|
||||
const { final_blow_char_id, final_blow_corp_id, final_blow_alliance_id } = args;
|
||||
|
||||
const attackerPortraitUrl = eveImageUrl('characters', final_blow_char_id, 'portrait', 64);
|
||||
const attackerCorpLogoUrl = eveImageUrl('corporations', final_blow_corp_id, 'logo', 32);
|
||||
const attackerAllianceLogoUrl = eveImageUrl('alliances', final_blow_alliance_id, 'logo', 32);
|
||||
|
||||
return {
|
||||
attackerPortraitUrl,
|
||||
attackerCorpLogoUrl,
|
||||
attackerAllianceLogoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPrimaryLogoAndTooltip(
|
||||
allianceUrl: string | null,
|
||||
corpUrl: string | null,
|
||||
allianceName: string,
|
||||
corpName: string,
|
||||
fallback: string,
|
||||
) {
|
||||
let url: string | null = null;
|
||||
let tooltip = '';
|
||||
|
||||
if (allianceUrl) {
|
||||
url = allianceUrl;
|
||||
tooltip = allianceName || fallback;
|
||||
} else if (corpUrl) {
|
||||
url = corpUrl;
|
||||
tooltip = corpName || fallback;
|
||||
}
|
||||
|
||||
return { url, tooltip };
|
||||
}
|
||||
|
||||
export function getAttackerPrimaryImageAndTooltip(
|
||||
isNpc: boolean,
|
||||
allianceUrl: string | null,
|
||||
corpUrl: string | null,
|
||||
allianceName: string,
|
||||
corpName: string,
|
||||
finalBlowShipTypeId: number,
|
||||
npcFallback: string = 'NPC Attacker',
|
||||
) {
|
||||
if (isNpc) {
|
||||
const shipUrl = buildAttackerShipUrl(finalBlowShipTypeId);
|
||||
return {
|
||||
url: shipUrl,
|
||||
tooltip: npcFallback,
|
||||
};
|
||||
}
|
||||
|
||||
return getPrimaryLogoAndTooltip(allianceUrl, corpUrl, allianceName, corpName, 'Attacker');
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import useLocalStorageState from 'use-local-storage-state';
|
||||
|
||||
export interface KillsWidgetSettings {
|
||||
compact: boolean;
|
||||
showAll: boolean;
|
||||
excludedSystems: number[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_KILLS_WIDGET_SETTINGS: KillsWidgetSettings = {
|
||||
compact: false,
|
||||
showAll: false,
|
||||
excludedSystems: [],
|
||||
version: 0,
|
||||
};
|
||||
|
||||
function mergeWithDefaults(settings?: Partial<KillsWidgetSettings>): KillsWidgetSettings {
|
||||
if (!settings) {
|
||||
return DEFAULT_KILLS_WIDGET_SETTINGS;
|
||||
}
|
||||
|
||||
return {
|
||||
...DEFAULT_KILLS_WIDGET_SETTINGS,
|
||||
...settings,
|
||||
excludedSystems: Array.isArray(settings.excludedSystems) ? settings.excludedSystems : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function useKillsWidgetSettings() {
|
||||
const [rawValue, setRawValue] = useLocalStorageState<KillsWidgetSettings | undefined>('kills:widget:settings');
|
||||
|
||||
const value = useMemo<KillsWidgetSettings>(() => {
|
||||
return mergeWithDefaults(rawValue);
|
||||
}, [rawValue]);
|
||||
|
||||
const setValue = useCallback(
|
||||
(newVal: KillsWidgetSettings | ((prev: KillsWidgetSettings) => KillsWidgetSettings)) => {
|
||||
setRawValue(prev => {
|
||||
const mergedPrev = mergeWithDefaults(prev);
|
||||
|
||||
const nextUnmerged = typeof newVal === 'function' ? newVal(mergedPrev) : newVal;
|
||||
|
||||
return mergeWithDefaults(nextUnmerged);
|
||||
});
|
||||
},
|
||||
[setRawValue],
|
||||
);
|
||||
|
||||
return [value, setValue] as const;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useCallback, useMemo, useState, useEffect, useRef } from 'react';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { OutCommand } from '@/hooks/Mapper/types/mapHandlers';
|
||||
import { DetailedKill } from '@/hooks/Mapper/types/kills';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { useKillsWidgetSettings } from './useKillsWidgetSettings';
|
||||
|
||||
interface UseSystemKillsProps {
|
||||
systemId?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
outCommand: (payload: any) => Promise<any>;
|
||||
showAllVisible?: boolean;
|
||||
sinceHours?: number;
|
||||
}
|
||||
|
||||
function combineKills(existing: DetailedKill[], incoming: DetailedKill[], sinceHours: number): DetailedKill[] {
|
||||
const cutoff = Date.now() - sinceHours * 60 * 60 * 1000;
|
||||
const byId: Record<string, DetailedKill> = {};
|
||||
|
||||
for (const kill of [...existing, ...incoming]) {
|
||||
if (!kill.kill_time) {
|
||||
continue;
|
||||
}
|
||||
const killTimeMs = new Date(kill.kill_time).valueOf();
|
||||
|
||||
if (killTimeMs >= cutoff) {
|
||||
byId[kill.killmail_id] = kill;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(byId);
|
||||
}
|
||||
|
||||
export function useSystemKills({ systemId, outCommand, showAllVisible = false, sinceHours = 24 }: UseSystemKillsProps) {
|
||||
const { data, update } = useMapRootState();
|
||||
const { detailedKills = {}, systems = [] } = data;
|
||||
|
||||
const [settings] = useKillsWidgetSettings();
|
||||
const excludedSystems = settings.excludedSystems;
|
||||
|
||||
const visibleSystemIds = useMemo(() => {
|
||||
return systems.map(s => s.id).filter(id => !excludedSystems.includes(Number(id)));
|
||||
}, [systems, excludedSystems]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const didFallbackFetch = useRef(Object.keys(detailedKills).length !== 0);
|
||||
|
||||
const mergeKillsIntoGlobal = useCallback(
|
||||
(killsMap: Record<string, DetailedKill[]>) => {
|
||||
update(prev => {
|
||||
const oldMap = prev.detailedKills ?? {};
|
||||
const updated: Record<string, DetailedKill[]> = { ...oldMap };
|
||||
|
||||
for (const [sid, newKills] of Object.entries(killsMap)) {
|
||||
const existing = updated[sid] ?? [];
|
||||
const combined = combineKills(existing, newKills, sinceHours);
|
||||
updated[sid] = combined;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
detailedKills: updated,
|
||||
};
|
||||
});
|
||||
},
|
||||
[update, sinceHours],
|
||||
);
|
||||
|
||||
const fetchKills = useCallback(
|
||||
async (forceFallback = false) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let eventType: OutCommand;
|
||||
let requestData: Record<string, unknown>;
|
||||
|
||||
if (showAllVisible || forceFallback) {
|
||||
eventType = OutCommand.getSystemsKills;
|
||||
requestData = {
|
||||
system_ids: visibleSystemIds,
|
||||
since_hours: sinceHours,
|
||||
};
|
||||
} else if (systemId) {
|
||||
eventType = OutCommand.getSystemKills;
|
||||
requestData = {
|
||||
system_id: systemId,
|
||||
since_hours: sinceHours,
|
||||
};
|
||||
} else {
|
||||
// If there's no system and not showing all, do nothing
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await outCommand({
|
||||
type: eventType,
|
||||
data: requestData,
|
||||
});
|
||||
|
||||
// Single system => `resp.kills`
|
||||
if (resp.kills) {
|
||||
const arr = resp.kills as DetailedKill[];
|
||||
const sid = systemId ?? 'unknown';
|
||||
mergeKillsIntoGlobal({ [sid]: arr });
|
||||
}
|
||||
// multiple => `resp.systems_kills`
|
||||
else if (resp.systems_kills) {
|
||||
mergeKillsIntoGlobal(resp.systems_kills as Record<string, DetailedKill[]>);
|
||||
} else {
|
||||
console.warn('[useSystemKills] Unexpected kills response =>', resp);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[useSystemKills] Failed to fetch kills:', err);
|
||||
setError(err instanceof Error ? err.message : 'Error fetching kills');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[showAllVisible, systemId, outCommand, visibleSystemIds, sinceHours, mergeKillsIntoGlobal],
|
||||
);
|
||||
|
||||
const debouncedFetchKills = useMemo(
|
||||
() =>
|
||||
debounce(fetchKills, 500, {
|
||||
leading: true,
|
||||
trailing: false,
|
||||
}),
|
||||
[fetchKills],
|
||||
);
|
||||
|
||||
const finalKills = useMemo(() => {
|
||||
if (showAllVisible) {
|
||||
return visibleSystemIds.flatMap(sid => detailedKills[sid] ?? []);
|
||||
} else if (systemId) {
|
||||
return detailedKills[systemId] ?? [];
|
||||
} else if (didFallbackFetch.current) {
|
||||
// if we already did a fallback, we may have data for multiple systems
|
||||
return visibleSystemIds.flatMap(sid => detailedKills[sid] ?? []);
|
||||
}
|
||||
return [];
|
||||
}, [showAllVisible, systemId, didFallbackFetch, visibleSystemIds, detailedKills]);
|
||||
|
||||
const effectiveIsLoading = isLoading && finalKills.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!systemId && !showAllVisible && !didFallbackFetch.current) {
|
||||
didFallbackFetch.current = true;
|
||||
// Cancel any queued debounced calls, then do the fallback.
|
||||
debouncedFetchKills.cancel();
|
||||
fetchKills(true); // forceFallback => fetch as though showAll
|
||||
}
|
||||
}, [systemId, showAllVisible, debouncedFetchKills, fetchKills, didFallbackFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleSystemIds.length === 0) return;
|
||||
|
||||
if (showAllVisible || systemId) {
|
||||
debouncedFetchKills();
|
||||
// Clean up the debounce on unmount or changes
|
||||
return () => debouncedFetchKills.cancel();
|
||||
}
|
||||
}, [showAllVisible, systemId, visibleSystemIds, debouncedFetchKills]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
debouncedFetchKills.cancel();
|
||||
fetchKills(); // immediate (non-debounced) call
|
||||
}, [debouncedFetchKills, fetchKills]);
|
||||
|
||||
return {
|
||||
kills: finalKills,
|
||||
isLoading: effectiveIsLoading,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './SystemKills';
|
||||
@@ -1,28 +1,29 @@
|
||||
import { PrimeIcons } from 'primereact/api';
|
||||
import { SignatureGroup, SystemSignature } from '@/hooks/Mapper/types';
|
||||
import { SystemViewStandalone, WHClassView } from '@/hooks/Mapper/components/ui-kit';
|
||||
import { SystemViewStandalone, TooltipPosition, WHClassView } from '@/hooks/Mapper/components/ui-kit';
|
||||
|
||||
import {
|
||||
k162Types,
|
||||
renderK162Type,
|
||||
} from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureK162TypeSelect';
|
||||
import { renderK162Type } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureK162TypeSelect';
|
||||
import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { renderName } from './renderName.tsx';
|
||||
import { K162_TYPES_MAP } from '@/hooks/Mapper/constants.ts';
|
||||
import { parseSignatureCustomInfo } from '@/hooks/Mapper/helpers/parseSignatureCustomInfo.ts';
|
||||
|
||||
export const renderInfoColumn = (row: SystemSignature) => {
|
||||
if (!row.group || row.group === SignatureGroup.Wormhole) {
|
||||
let k162TypeOption = null;
|
||||
if (row.custom_info) {
|
||||
const customInfo = JSON.parse(row.custom_info);
|
||||
if (customInfo.k162Type) {
|
||||
k162TypeOption = k162Types.find(x => x.value === customInfo.k162Type);
|
||||
}
|
||||
}
|
||||
const customInfo = parseSignatureCustomInfo(row.custom_info);
|
||||
|
||||
const k162TypeOption = customInfo.k162Type ? K162_TYPES_MAP[customInfo.k162Type] : null;
|
||||
|
||||
return (
|
||||
<div className="flex justify-start items-center gap-[4px]">
|
||||
{customInfo.isEOL && (
|
||||
<WdTooltipWrapper offset={5} position={TooltipPosition.top} content="Signature marked as EOL">
|
||||
<div className="pi pi-clock text-fuchsia-400 text-[11px] mr-[2px]"></div>
|
||||
</WdTooltipWrapper>
|
||||
)}
|
||||
|
||||
{row.type && (
|
||||
<WHClassView
|
||||
className="text-[11px]"
|
||||
@@ -34,7 +35,7 @@ export const renderInfoColumn = (row: SystemSignature) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!row.linked_system && row.type === 'K162' && !!k162TypeOption && <>{renderK162Type(k162TypeOption)}</>}
|
||||
{!row.linked_system && row.type === 'K162' && k162TypeOption && renderK162Type(k162TypeOption)}
|
||||
|
||||
{row.linked_system && (
|
||||
<>
|
||||
|
||||
@@ -70,6 +70,11 @@ export const SystemStructures: React.FC = () => {
|
||||
<WdImgButton
|
||||
className={`${PrimeIcons.CLOCK} text-sky-400 hover:text-sky-200 transition duration-300`}
|
||||
onClick={handlePasteTimer}
|
||||
tooltip={{
|
||||
position: TooltipPosition.left,
|
||||
// @ts-ignore
|
||||
content: 'Add Structures/Timer',
|
||||
}}
|
||||
/>
|
||||
<WdImgButton
|
||||
className={PrimeIcons.QUESTION_CIRCLE}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
.TableRowCompact {
|
||||
height: 8px;
|
||||
max-height: 8px;
|
||||
font-size: 12px !important;
|
||||
line-height: 8px;
|
||||
}
|
||||
|
||||
.Table {
|
||||
font-size: 12px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.TableRowCompact {
|
||||
height: 8px;
|
||||
max-height: 8px;
|
||||
font-size: 12px !important;
|
||||
line-height: 8px;
|
||||
}
|
||||
|
||||
.Tooltip {
|
||||
white-space: pre-line; // or pre-wrap
|
||||
line-height: 1.2rem;
|
||||
}
|
||||
|
||||
.Table {
|
||||
font-size: 12px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.Table .p-datatable-tbody > tr > td {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.Tooltip {
|
||||
white-space: pre-line;
|
||||
line-height: 1.2rem;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export const SystemStructuresContent: React.FC<SystemStructuresContentProps> = (
|
||||
size="small"
|
||||
sortMode="single"
|
||||
rowHover
|
||||
style={{ tableLayout: 'fixed', width: '100%' }}
|
||||
onRowClick={handleRowClick}
|
||||
onRowDoubleClick={handleRowDoubleClick}
|
||||
rowClassName={rowData => {
|
||||
@@ -74,11 +75,56 @@ export const SystemStructuresContent: React.FC<SystemStructuresContentProps> = (
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Column header="Type" body={renderTypeCell} style={{ width: '160px' }} />
|
||||
<Column field="name" header="Name" style={{ width: '120px' }} />
|
||||
<Column header="Owner" body={renderOwnerCell} style={{ width: '120px' }} />
|
||||
<Column field="status" header="Status" style={{ width: '100px' }} />
|
||||
<Column header="Timer" body={renderTimerCell} style={{ width: '110px' }} />
|
||||
<Column
|
||||
header="Type"
|
||||
body={renderTypeCell}
|
||||
style={{
|
||||
width: '160px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
/>
|
||||
<Column
|
||||
field="name"
|
||||
header="Name"
|
||||
style={{
|
||||
width: '120px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
/>
|
||||
<Column
|
||||
header="Owner"
|
||||
body={renderOwnerCell}
|
||||
style={{
|
||||
width: '120px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
/>
|
||||
<Column
|
||||
field="status"
|
||||
header="Status"
|
||||
style={{
|
||||
width: '100px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
/>
|
||||
<Column
|
||||
header="Timer"
|
||||
body={renderTimerCell}
|
||||
style={{
|
||||
width: '110px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
/>
|
||||
<Column
|
||||
body={(rowData: StructureItem) => (
|
||||
<i
|
||||
@@ -90,7 +136,13 @@ export const SystemStructuresContent: React.FC<SystemStructuresContentProps> = (
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
style={{ width: '40px', textAlign: 'center' }}
|
||||
style={{
|
||||
width: '40px',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
/>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './SystemInfo';
|
||||
export * from './RoutesWidget';
|
||||
export * from './SystemSignatures';
|
||||
export * from './SystemStructures';
|
||||
export * from './SystemKills';
|
||||
|
||||
@@ -2,7 +2,9 @@ import clsx from 'clsx';
|
||||
import classes from './PassageCard.module.scss';
|
||||
import { Passage } from '@/hooks/Mapper/types';
|
||||
import { TimeAgo } from '@/hooks/Mapper/components/ui-kit';
|
||||
import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper';
|
||||
import { kgToTons } from '@/hooks/Mapper/utils/kgToTons.ts';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type PassageCardType = {
|
||||
// compact?: boolean;
|
||||
@@ -26,6 +28,11 @@ export const getShipName = (name: string) => {
|
||||
export const PassageCard = ({ inserted_at, character: char, ship }: PassageCardType) => {
|
||||
const isOwn = false;
|
||||
|
||||
const insertedAt = useMemo(() => {
|
||||
const date = new Date(inserted_at);
|
||||
return date.toLocaleString();
|
||||
}, [inserted_at]);
|
||||
|
||||
return (
|
||||
<div className={clsx(classes.CharacterCard, 'w-full text-xs', 'flex flex-col box-border')}>
|
||||
<div className="flex flex-col justify-between px-2 py-1 gap-1">
|
||||
@@ -76,7 +83,9 @@ export const PassageCard = ({ inserted_at, character: char, ship }: PassageCardT
|
||||
{/*time and class*/}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-stone-400">
|
||||
<TimeAgo timestamp={inserted_at} />
|
||||
<WdTooltipWrapper content={insertedAt}>
|
||||
<TimeAgo timestamp={inserted_at} />
|
||||
</WdTooltipWrapper>
|
||||
</span>
|
||||
|
||||
<div className="text-stone-400">{kgToTons(parseInt(ship.ship_type_info.mass))}</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import styles from './MapSettings.module.scss';
|
||||
|
||||
import { WdCheckbox } from '@/hooks/Mapper/components/ui-kit';
|
||||
|
||||
interface PrettySwitchboxProps {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PrettySwitchbox } from '@/hooks/Mapper/components/mapRootContent/components/MapSettings/components';
|
||||
import { WIDGETS_CHECKBOXES_PROPS, WidgetsIds } from '@/hooks/Mapper/components/mapInterface/constants.tsx';
|
||||
import { getWidgetsCheckboxesProps, WidgetsIds } from '@/hooks/Mapper/components/mapInterface/constants.tsx';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
@@ -9,17 +9,20 @@ export interface WidgetsSettingsProps {}
|
||||
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
export const WidgetsSettings = ({}: WidgetsSettingsProps) => {
|
||||
const { windowsSettings, toggleWidgetVisibility, resetWidgets } = useMapRootState();
|
||||
const { windowsSettings, toggleWidgetVisibility, resetWidgets, data } = useMapRootState();
|
||||
|
||||
const handleWidgetSettingsChange = useCallback(
|
||||
(widget: WidgetsIds) => toggleWidgetVisibility(widget),
|
||||
[toggleWidgetVisibility],
|
||||
);
|
||||
|
||||
const detailedKillsDisabled = data.options?.detailedKillsDisabled === true;
|
||||
const widgetProps = getWidgetsCheckboxesProps(detailedKillsDisabled);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-2">
|
||||
<div>
|
||||
{WIDGETS_CHECKBOXES_PROPS.map(widget => (
|
||||
{widgetProps.map(widget => (
|
||||
<PrettySwitchbox
|
||||
key={widget.id}
|
||||
label={widget.label}
|
||||
|
||||
@@ -53,6 +53,7 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map
|
||||
...out,
|
||||
custom_info: JSON.stringify({
|
||||
k162Type: values.k162Type,
|
||||
isEOL: values.isEOL,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -127,14 +128,17 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map
|
||||
const { linked_system, custom_info, ...rest } = signatureData;
|
||||
|
||||
let k162Type = null;
|
||||
let isEOL = false;
|
||||
if (custom_info) {
|
||||
const customInfo = JSON.parse(custom_info);
|
||||
k162Type = customInfo.k162Type;
|
||||
isEOL = customInfo.isEOL;
|
||||
}
|
||||
|
||||
signatureForm.reset({
|
||||
linked_system: linked_system?.solar_system_id.toString() ?? undefined,
|
||||
k162Type: k162Type,
|
||||
isEOL: isEOL,
|
||||
...rest,
|
||||
});
|
||||
}, [signatureForm, signatureData]);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { InputSwitch } from 'primereact/inputswitch';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { SystemSignature } from '@/hooks/Mapper/types';
|
||||
|
||||
export interface SignatureEOLCheckboxProps {
|
||||
name: string;
|
||||
defaultValue?: boolean;
|
||||
}
|
||||
|
||||
export const SignatureEOLCheckbox = ({ name, defaultValue = false }: SignatureEOLCheckboxProps) => {
|
||||
const { control } = useFormContext<SystemSignature>();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
// @ts-ignore
|
||||
name={name}
|
||||
control={control}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field }) => {
|
||||
return <InputSwitch className="my-1" checked={!!field.value} onChange={e => field.onChange(e.value)} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './SignatureEOLCheckbox.tsx';
|
||||
@@ -3,6 +3,7 @@ import { SystemSignature } from '@/hooks/Mapper/types';
|
||||
import { SignatureWormholeTypeSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureWormholeTypeSelect';
|
||||
import { SignatureK162TypeSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureK162TypeSelect';
|
||||
import { SignatureLeadsToSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureLeadsToSelect';
|
||||
import { SignatureEOLCheckbox } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureEOLCheckbox';
|
||||
|
||||
export const SignatureGroupContentWormholes = () => {
|
||||
const { watch } = useFormContext<SystemSignature>();
|
||||
@@ -26,6 +27,11 @@ export const SignatureGroupContentWormholes = () => {
|
||||
<span>Leads To:</span>
|
||||
<SignatureLeadsToSelect name="linked_system" />
|
||||
</label>
|
||||
|
||||
<label className="grid grid-cols-[100px_250px_1fr] gap-2 items-center text-[14px]">
|
||||
<span>EOL:</span>
|
||||
<SignatureEOLCheckbox name="isEOL" />
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,100 +3,8 @@ import clsx from 'clsx';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { useMemo } from 'react';
|
||||
import { SystemSignature } from '@/hooks/Mapper/types';
|
||||
import { WHClassView } from '@/hooks/Mapper/components/ui-kit';
|
||||
|
||||
export const k162Types = [
|
||||
{
|
||||
label: 'Hi-Sec',
|
||||
value: 'hs',
|
||||
whClassName: 'A641',
|
||||
},
|
||||
{
|
||||
label: 'Low-Sec',
|
||||
value: 'ls',
|
||||
whClassName: 'J377',
|
||||
},
|
||||
{
|
||||
label: 'Null-Sec',
|
||||
value: 'ns',
|
||||
whClassName: 'C248',
|
||||
},
|
||||
{
|
||||
label: 'C1',
|
||||
value: 'c1',
|
||||
whClassName: 'E004',
|
||||
},
|
||||
{
|
||||
label: 'C2',
|
||||
value: 'c2',
|
||||
whClassName: 'D382',
|
||||
},
|
||||
{
|
||||
label: 'C3',
|
||||
value: 'c3',
|
||||
whClassName: 'L477',
|
||||
},
|
||||
{
|
||||
label: 'C4',
|
||||
value: 'c4',
|
||||
whClassName: 'M001',
|
||||
},
|
||||
{
|
||||
label: 'C5',
|
||||
value: 'c5',
|
||||
whClassName: 'L614',
|
||||
},
|
||||
{
|
||||
label: 'C6',
|
||||
value: 'c6',
|
||||
whClassName: 'G008',
|
||||
},
|
||||
{
|
||||
label: 'C13',
|
||||
value: 'c13',
|
||||
whClassName: 'A009',
|
||||
},
|
||||
{
|
||||
label: 'Thera',
|
||||
value: 'thera',
|
||||
whClassName: 'F353',
|
||||
},
|
||||
{
|
||||
label: 'Pochven',
|
||||
value: 'pochven',
|
||||
whClassName: 'F216',
|
||||
},
|
||||
];
|
||||
|
||||
const renderNoValue = () => <div className="flex gap-2 items-center">-Unknown-</div>;
|
||||
|
||||
// @ts-ignore
|
||||
export const renderK162Type = (option: {
|
||||
label?: string;
|
||||
value: string;
|
||||
security?: string;
|
||||
system_class?: number;
|
||||
whClassName?: string;
|
||||
}) => {
|
||||
if (!option) {
|
||||
return renderNoValue();
|
||||
}
|
||||
const { value, whClassName = '' } = option;
|
||||
if (value == null) {
|
||||
return renderNoValue();
|
||||
}
|
||||
|
||||
return (
|
||||
<WHClassView
|
||||
classNameWh="!text-[11px] !font-bold"
|
||||
hideWhClassName
|
||||
hideTooltip
|
||||
whClassName={whClassName}
|
||||
noOffset
|
||||
useShortTitle
|
||||
/>
|
||||
);
|
||||
};
|
||||
import { K162_TYPES } from '@/hooks/Mapper/constants.ts';
|
||||
import { renderK162Type } from '.';
|
||||
|
||||
export interface SignatureK162TypeSelectProps {
|
||||
name: string;
|
||||
@@ -107,7 +15,7 @@ export const SignatureK162TypeSelect = ({ name, defaultValue = '' }: SignatureK1
|
||||
const { control } = useFormContext<SystemSignature>();
|
||||
|
||||
const options = useMemo(() => {
|
||||
return [{ value: null }, ...k162Types];
|
||||
return [{ value: null }, ...K162_TYPES];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './SignatureK162TypeSelect.tsx';
|
||||
export * from './renderK162Type.tsx';
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { WHClassView } from '@/hooks/Mapper/components/ui-kit';
|
||||
import { K162Type } from '@/hooks/Mapper/constants.ts';
|
||||
|
||||
const renderNoValue = () => <div className="flex gap-2 items-center">-Unknown-</div>;
|
||||
|
||||
export const renderK162Type = (option: K162Type) => {
|
||||
if (!option) {
|
||||
return renderNoValue();
|
||||
}
|
||||
|
||||
const { value, whClassName = '' } = option;
|
||||
if (value == null) {
|
||||
return renderNoValue();
|
||||
}
|
||||
|
||||
return (
|
||||
<WHClassView
|
||||
classNameWh="!text-[11px] !font-bold"
|
||||
hideWhClassName
|
||||
hideTooltip
|
||||
whClassName={whClassName}
|
||||
noOffset
|
||||
useShortTitle
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -15,7 +15,7 @@
|
||||
border-style: solid;
|
||||
border-color: #272727;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
border-radius: 3px;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.CharName {
|
||||
@@ -26,7 +26,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
.CharIcon {}
|
||||
.CharIcon {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.CharRow {
|
||||
display: grid;
|
||||
|
||||
@@ -3,7 +3,7 @@ import clsx from 'clsx';
|
||||
import classes from './CharacterCard.module.scss';
|
||||
import { SystemView } from '@/hooks/Mapper/components/ui-kit/SystemView';
|
||||
import { CharacterTypeRaw, WithIsOwnCharacter } from '@/hooks/Mapper/types';
|
||||
import { Commands } from '@/hooks/Mapper/types/mapHandlers.ts';
|
||||
import { Commands } from '@/hooks/Mapper/types/mapHandlers';
|
||||
import { emitMapEvent } from '@/hooks/Mapper/events';
|
||||
|
||||
type CharacterCardProps = {
|
||||
@@ -18,12 +18,8 @@ const SHIP_NAME_RX = /u'|'/g;
|
||||
export const getShipName = (name: string) => {
|
||||
return name
|
||||
.replace(SHIP_NAME_RX, '')
|
||||
.replace(/\\u([\dA-Fa-f]{4})/g, (_, grp) => {
|
||||
return String.fromCharCode(parseInt(grp, 16));
|
||||
})
|
||||
.replace(/\\x([\dA-Fa-f]{2})/g, (_, grp) => {
|
||||
return String.fromCharCode(parseInt(grp, 16));
|
||||
});
|
||||
.replace(/\\u([\dA-Fa-f]{4})/g, (_, grp) => String.fromCharCode(parseInt(grp, 16)))
|
||||
.replace(/\\x([\dA-Fa-f]{2})/g, (_, grp) => String.fromCharCode(parseInt(grp, 16)));
|
||||
};
|
||||
|
||||
export const CharacterCard = ({
|
||||
@@ -47,7 +43,9 @@ export const CharacterCard = ({
|
||||
{!compact && (
|
||||
<span
|
||||
className={clsx(classes.EveIcon, classes.CharIcon, 'wd-bg-default')}
|
||||
style={{ backgroundImage: `url(https://images.evetech.net/characters/${char.eve_id}/portrait)` }}
|
||||
style={{
|
||||
backgroundImage: `url(https://images.evetech.net/characters/${char.eve_id}/portrait)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col flex-grow">
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
import React, {
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
MouseEvent,
|
||||
MouseEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import React, { ForwardedRef, forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import classes from './WdTooltip.module.scss';
|
||||
import clsx from 'clsx';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { WithClassName } from '@/hooks/Mapper/types/common.ts';
|
||||
import classes from './WdTooltip.module.scss';
|
||||
|
||||
export enum TooltipPosition {
|
||||
default = 'default',
|
||||
@@ -29,11 +17,7 @@ export interface TooltipProps {
|
||||
offset?: number;
|
||||
content: (() => React.ReactNode) | React.ReactNode;
|
||||
targetSelector?: string;
|
||||
}
|
||||
|
||||
export interface WdTooltipHandlers {
|
||||
show: MouseEventHandler;
|
||||
hide: MouseEventHandler;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export interface OffsetPosition {
|
||||
@@ -41,169 +25,185 @@ export interface OffsetPosition {
|
||||
left: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
export const WdTooltip = forwardRef((props: TooltipProps & WithClassName, ref: ForwardedRef<WdTooltipHandlers>) => {
|
||||
const { content, targetSelector, position: tPosition = TooltipPosition.default, className, offset = 5 } = props;
|
||||
export interface WdTooltipHandlers {
|
||||
show: (e?: React.MouseEvent) => void;
|
||||
hide: (e?: React.MouseEvent) => void;
|
||||
getIsMouseInside: () => boolean;
|
||||
}
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [position, setPosition] = useState<OffsetPosition | null>(null);
|
||||
const [ev, setEv] = useState<MouseEvent>();
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
export const WdTooltip = forwardRef(
|
||||
(props: TooltipProps & { className?: string }, ref: ForwardedRef<WdTooltipHandlers>) => {
|
||||
const {
|
||||
content,
|
||||
targetSelector,
|
||||
position: tPosition = TooltipPosition.default,
|
||||
className,
|
||||
offset = 5,
|
||||
interactive = false,
|
||||
} = props;
|
||||
|
||||
const calcTooltipPosition = useCallback(({ x, y }: { x: number; y: number }) => {
|
||||
let newLeft = x;
|
||||
let newTop = y;
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [pos, setPos] = useState<OffsetPosition | null>(null);
|
||||
const [ev, setEv] = useState<React.MouseEvent>();
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const [isMouseInsideTooltip, setIsMouseInsideTooltip] = useState(false);
|
||||
|
||||
if (!tooltipRef.current) {
|
||||
const calcTooltipPosition = useCallback(({ x, y }: { x: number; y: number }) => {
|
||||
if (!tooltipRef.current) return { left: x, top: y };
|
||||
const tooltipWidth = tooltipRef.current.offsetWidth;
|
||||
const tooltipHeight = tooltipRef.current.offsetHeight;
|
||||
let newLeft = x;
|
||||
let newTop = y;
|
||||
|
||||
if (newLeft < 0) newLeft = 10;
|
||||
if (newTop < 0) newTop = 10;
|
||||
if (newLeft + tooltipWidth + 10 > window.innerWidth) {
|
||||
newLeft = window.innerWidth - tooltipWidth - 10;
|
||||
}
|
||||
if (newTop + tooltipHeight + 10 > window.innerHeight) {
|
||||
newTop = window.innerHeight - tooltipHeight - 10;
|
||||
}
|
||||
return { left: newLeft, top: newTop };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const tooltipWidth = tooltipRef.current.offsetWidth;
|
||||
const tooltipHeight = tooltipRef.current.offsetHeight;
|
||||
useImperativeHandle(ref, () => ({
|
||||
show: (mouseEvt?: React.MouseEvent) => {
|
||||
if (mouseEvt) setEv(mouseEvt);
|
||||
setPos(null);
|
||||
setVisible(true);
|
||||
},
|
||||
hide: () => {
|
||||
setVisible(false);
|
||||
},
|
||||
getIsMouseInside: () => isMouseInsideTooltip,
|
||||
}));
|
||||
|
||||
if (newLeft < 0) {
|
||||
newLeft = 10;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!tooltipRef.current || !ev) return;
|
||||
const tooltipEl = tooltipRef.current;
|
||||
const { clientX, clientY, target } = ev;
|
||||
const targetBounds = (target as HTMLElement).getBoundingClientRect();
|
||||
|
||||
if (newTop < 0) {
|
||||
newTop = 10;
|
||||
}
|
||||
let offsetX = clientX;
|
||||
let offsetY = clientY;
|
||||
|
||||
if (newLeft + tooltipWidth + 10 > window.innerWidth) {
|
||||
newLeft = window.innerWidth - tooltipWidth - 10;
|
||||
}
|
||||
if (newTop + tooltipHeight + 10 > window.innerHeight) {
|
||||
newTop = window.innerHeight - tooltipHeight - 10;
|
||||
}
|
||||
return { left: newLeft, top: newTop };
|
||||
}, []);
|
||||
if (tPosition === TooltipPosition.left) {
|
||||
const tooltipBounds = tooltipEl.getBoundingClientRect();
|
||||
offsetX = targetBounds.left - tooltipBounds.width - offset;
|
||||
offsetY = targetBounds.y + targetBounds.height / 2 - tooltipBounds.height / 2;
|
||||
if (offsetX <= 0) {
|
||||
offsetX = targetBounds.left + targetBounds.width + offset;
|
||||
}
|
||||
setPos(calcTooltipPosition({ x: offsetX, y: offsetY }));
|
||||
return;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!tooltipRef.current || !ev) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { clientX, clientY, target } = ev;
|
||||
|
||||
const targetBounds = (target as HTMLElement).getBoundingClientRect();
|
||||
const tooltipBounds = tooltipRef.current.getBoundingClientRect();
|
||||
|
||||
let offsetX = clientX;
|
||||
let offsetY = clientY;
|
||||
|
||||
if (tPosition === TooltipPosition.left) {
|
||||
offsetX = targetBounds.left - tooltipBounds.width - offset;
|
||||
offsetY = targetBounds.y + targetBounds.height / 2 - tooltipBounds.height / 2;
|
||||
|
||||
if (offsetX <= 0) {
|
||||
if (tPosition === TooltipPosition.right) {
|
||||
offsetX = targetBounds.left + targetBounds.width + offset;
|
||||
}
|
||||
|
||||
setPosition(calcTooltipPosition({ x: offsetX, y: offsetY }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (tPosition === TooltipPosition.right) {
|
||||
offsetX = targetBounds.left + targetBounds.width + offset;
|
||||
offsetY = targetBounds.y + targetBounds.height / 2 - tooltipBounds.height / 2;
|
||||
|
||||
setPosition(calcTooltipPosition({ x: offsetX, y: offsetY }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (tPosition === TooltipPosition.top) {
|
||||
offsetY = targetBounds.top - tooltipBounds.height - offset;
|
||||
offsetX = targetBounds.x + targetBounds.width / 2 - tooltipBounds.width / 2;
|
||||
|
||||
setPosition(calcTooltipPosition({ x: offsetX, y: offsetY }));
|
||||
return;
|
||||
}
|
||||
|
||||
// default case
|
||||
setPosition(calcTooltipPosition({ x: clientX, y: clientY }));
|
||||
}, [calcTooltipPosition, ev, tPosition, offset]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
show: e => {
|
||||
setEv(e);
|
||||
setVisible(true);
|
||||
setPosition(null);
|
||||
},
|
||||
hide: () => {
|
||||
setVisible(false);
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (targetSelector == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const targetElement = e.target as HTMLElement;
|
||||
|
||||
if (!targetElement) {
|
||||
setVisible(false);
|
||||
offsetY = targetBounds.y + targetBounds.height / 2 - tooltipEl.offsetHeight / 2;
|
||||
setPos(calcTooltipPosition({ x: offsetX, y: offsetY }));
|
||||
return;
|
||||
}
|
||||
|
||||
const nodesFound = [...(targetElement?.parentElement?.querySelectorAll(targetSelector) ?? [])];
|
||||
|
||||
if (!nodesFound.includes(targetElement)) {
|
||||
setVisible(false);
|
||||
if (tPosition === TooltipPosition.top) {
|
||||
offsetY = targetBounds.top - tooltipEl.offsetHeight - offset;
|
||||
offsetX = targetBounds.x + targetBounds.width / 2 - tooltipEl.offsetWidth / 2;
|
||||
setPos(calcTooltipPosition({ x: offsetX, y: offsetY }));
|
||||
return;
|
||||
}
|
||||
|
||||
setVisible(true);
|
||||
if (tooltipRef.current) {
|
||||
const { clientX, clientY } = e;
|
||||
const tooltipWidth = tooltipRef.current.offsetWidth;
|
||||
const tooltipHeight = tooltipRef.current.offsetHeight;
|
||||
let newLeft = clientX + 10;
|
||||
let newTop = clientY + 10;
|
||||
if (newLeft + tooltipWidth + 10 > window.innerWidth) {
|
||||
newLeft = window.innerWidth - tooltipWidth - 10;
|
||||
}
|
||||
if (newTop + tooltipHeight + 10 > window.innerHeight) {
|
||||
newTop = window.innerHeight - tooltipHeight - 10;
|
||||
}
|
||||
setPosition({ top: newTop, left: newLeft });
|
||||
if (tPosition === TooltipPosition.bottom) {
|
||||
offsetY = targetBounds.bottom + offset;
|
||||
offsetX = targetBounds.x + targetBounds.width / 2 - tooltipEl.offsetWidth / 2;
|
||||
setPos(calcTooltipPosition({ x: offsetX, y: offsetY }));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const deb = debounce(handleMouseMove, 10);
|
||||
setPos(calcTooltipPosition({ x: offsetX, y: offsetY }));
|
||||
}, [calcTooltipPosition, ev, tPosition, offset]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
document.addEventListener('mousemove', deb);
|
||||
return () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
document.removeEventListener('mousemove', deb);
|
||||
};
|
||||
}, [targetSelector]);
|
||||
useEffect(() => {
|
||||
if (!targetSelector) return;
|
||||
|
||||
return createPortal(
|
||||
visible && (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className={clsx(
|
||||
classes.tooltip,
|
||||
'pointer-events-none',
|
||||
'absolute px-2 py-2',
|
||||
'border rounded border-green-300 border-opacity-10 bg-stone-900 bg-opacity-90',
|
||||
{ ['invisible']: position === null },
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
top: position?.top ?? 0,
|
||||
left: position?.left ?? 0,
|
||||
zIndex: 10000,
|
||||
}}
|
||||
>
|
||||
{typeof content === 'function' ? content() : content}
|
||||
</div>
|
||||
),
|
||||
document.body,
|
||||
);
|
||||
});
|
||||
function handleMouseMove(nativeEvt: globalThis.MouseEvent) {
|
||||
const targetEl = nativeEvt.target as HTMLElement | null;
|
||||
if (!targetEl) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
const triggerEl = targetEl.closest(targetSelector!);
|
||||
const isInsideTooltip = interactive && tooltipRef.current?.contains(targetEl);
|
||||
|
||||
if (!triggerEl && !isInsideTooltip) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
setVisible(true);
|
||||
|
||||
if (triggerEl && tooltipRef.current) {
|
||||
const rect = triggerEl.getBoundingClientRect();
|
||||
const tooltipEl = tooltipRef.current;
|
||||
let x = nativeEvt.clientX;
|
||||
let y = nativeEvt.clientY;
|
||||
|
||||
if (tPosition === TooltipPosition.left) {
|
||||
x = rect.left - tooltipEl.offsetWidth - offset;
|
||||
y = rect.y + rect.height / 2 - tooltipEl.offsetHeight / 2;
|
||||
if (x <= 0) {
|
||||
x = rect.left + rect.width + offset;
|
||||
}
|
||||
} else if (tPosition === TooltipPosition.right) {
|
||||
x = rect.left + rect.width + offset;
|
||||
y = rect.y + rect.height / 2 - tooltipEl.offsetHeight / 2;
|
||||
} else if (tPosition === TooltipPosition.top) {
|
||||
x = rect.x + rect.width / 2 - tooltipEl.offsetWidth / 2;
|
||||
y = rect.top - tooltipEl.offsetHeight - offset;
|
||||
} else if (tPosition === TooltipPosition.bottom) {
|
||||
x = rect.x + rect.width / 2 - tooltipEl.offsetWidth / 2;
|
||||
y = rect.bottom + offset;
|
||||
}
|
||||
|
||||
setPos(calcTooltipPosition({ x, y }));
|
||||
}
|
||||
}
|
||||
|
||||
const debounced = debounce(handleMouseMove, 10);
|
||||
|
||||
const listener: EventListener = evt => {
|
||||
debounced(evt as globalThis.MouseEvent);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', listener);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', listener);
|
||||
};
|
||||
}, [targetSelector, interactive, tPosition, offset, calcTooltipPosition]);
|
||||
|
||||
return createPortal(
|
||||
visible && (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className={clsx(
|
||||
classes.tooltip,
|
||||
interactive ? 'pointer-events-auto' : 'pointer-events-none',
|
||||
'absolute p-1 border rounded-sm border-green-300 border-opacity-10 bg-stone-900 bg-opacity-90',
|
||||
pos === null ? 'invisible' : '',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
top: pos?.top ?? 0,
|
||||
left: pos?.left ?? 0,
|
||||
zIndex: 10000,
|
||||
}}
|
||||
onMouseEnter={() => interactive && setIsMouseInsideTooltip(true)}
|
||||
onMouseLeave={() => interactive && setIsMouseInsideTooltip(false)}
|
||||
>
|
||||
{typeof content === 'function' ? content() : content}
|
||||
</div>
|
||||
),
|
||||
document.body,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
WdTooltip.displayName = 'WdTooltip';
|
||||
|
||||
@@ -1,49 +1,57 @@
|
||||
import React, { HTMLProps, MouseEventHandler, useCallback, useRef } from 'react';
|
||||
|
||||
import classes from './WdTooltipWrapper.module.scss';
|
||||
import { WithChildren, WithClassName } from '@/hooks/Mapper/types/common.ts';
|
||||
import { TooltipProps, WdTooltip, WdTooltipHandlers } from '@/hooks/Mapper/components/ui-kit';
|
||||
import { forwardRef, HTMLProps, ReactNode } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { WdTooltip, WdTooltipHandlers, TooltipProps } from '@/hooks/Mapper/components/ui-kit';
|
||||
import classes from './WdTooltipWrapper.module.scss';
|
||||
|
||||
type TooltipSize = 'xs' | 'sm' | 'md' | 'lg';
|
||||
|
||||
export type WdTooltipWrapperProps = {
|
||||
content?: (() => React.ReactNode) | React.ReactNode;
|
||||
} & WithChildren &
|
||||
WithClassName &
|
||||
HTMLProps<HTMLDivElement> &
|
||||
content?: (() => ReactNode) | ReactNode;
|
||||
size?: TooltipSize;
|
||||
interactive?: boolean;
|
||||
} & Omit<HTMLProps<HTMLDivElement>, 'content' | 'size'> &
|
||||
Omit<TooltipProps, 'content'>;
|
||||
|
||||
export const WdTooltipWrapper = ({
|
||||
className,
|
||||
children,
|
||||
content,
|
||||
offset,
|
||||
position,
|
||||
targetSelector,
|
||||
...props
|
||||
}: WdTooltipWrapperProps) => {
|
||||
const tooltipRef = useRef<WdTooltipHandlers>(null);
|
||||
const handleShowDeleteTooltip: MouseEventHandler = useCallback(e => tooltipRef.current?.show(e), []);
|
||||
const handleHideDeleteTooltip: MouseEventHandler = useCallback(e => tooltipRef.current?.hide(e), []);
|
||||
export const WdTooltipWrapper = forwardRef<WdTooltipHandlers, WdTooltipWrapperProps>(
|
||||
(
|
||||
{ className, children, content, offset, position, targetSelector, interactive = false, size, ...props },
|
||||
forwardedRef,
|
||||
) => {
|
||||
const suffix = Math.random().toString(36).slice(2, 7);
|
||||
const autoClass = `wdTooltipAutoTrigger-${suffix}`;
|
||||
const finalTargetSelector = targetSelector || `.${autoClass}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(classes.WdTooltipWrapperRoot, className)}
|
||||
{...props}
|
||||
{...(content && {
|
||||
onMouseEnter: handleShowDeleteTooltip,
|
||||
onMouseLeave: handleHideDeleteTooltip,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
return (
|
||||
<div className={clsx(classes.WdTooltipWrapperRoot, className)} {...props}>
|
||||
{targetSelector ? <>{children}</> : <div className={autoClass}>{children}</div>}
|
||||
|
||||
<WdTooltip
|
||||
ref={forwardedRef}
|
||||
offset={offset}
|
||||
position={position}
|
||||
content={content}
|
||||
interactive={interactive}
|
||||
targetSelector={finalTargetSelector}
|
||||
className={size ? sizeClass(size) : undefined}
|
||||
/>
|
||||
</div>
|
||||
<WdTooltip
|
||||
ref={tooltipRef}
|
||||
offset={offset}
|
||||
position={position}
|
||||
content={content}
|
||||
targetSelector={targetSelector}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
WdTooltipWrapper.displayName = 'WdTooltipWrapper';
|
||||
|
||||
function sizeClass(size: TooltipSize) {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return classes.wdTooltipSizeXs;
|
||||
case 'sm':
|
||||
return classes.wdTooltipSizeSm;
|
||||
case 'md':
|
||||
return classes.wdTooltipSizeMd;
|
||||
case 'lg':
|
||||
return classes.wdTooltipSizeLg;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,14 +76,22 @@ export const WindowWrapper = ({ onResize, onDrag, ...window }: WindowWrapperProp
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export type ViewPortProps = { w: number; h: number };
|
||||
export type WindowsManagerOnChange = (props: { windows: WindowProps[]; viewPort: ViewPortProps }) => void;
|
||||
|
||||
type WindowManagerProps = {
|
||||
windows: WindowProps[];
|
||||
viewPort?: ViewPortProps;
|
||||
dragSelector?: string;
|
||||
onChange?(windows: WindowProps[]): void;
|
||||
onChange?: WindowsManagerOnChange;
|
||||
};
|
||||
|
||||
export const WindowManager: React.FC<WindowManagerProps> = ({ windows: initialWindows, dragSelector, onChange }) => {
|
||||
export const WindowManager: React.FC<WindowManagerProps> = ({
|
||||
windows: initialWindows,
|
||||
viewPort,
|
||||
dragSelector,
|
||||
onChange,
|
||||
}) => {
|
||||
const [windows, setWindows] = useState(
|
||||
initialWindows.map((window, index) => ({
|
||||
...window,
|
||||
@@ -91,6 +99,16 @@ export const WindowManager: React.FC<WindowManagerProps> = ({ windows: initialWi
|
||||
})),
|
||||
);
|
||||
|
||||
const refPrevSize = useRef({ w: 0, h: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewPort) {
|
||||
return;
|
||||
}
|
||||
|
||||
refPrevSize.current = viewPort;
|
||||
}, [viewPort]);
|
||||
|
||||
useEffect(() => {
|
||||
setWindows(initialWindows.slice(0));
|
||||
}, [initialWindows]);
|
||||
@@ -102,14 +120,15 @@ export const WindowManager: React.FC<WindowManagerProps> = ({ windows: initialWi
|
||||
const startMousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const startWindowStateRef = useRef<{ x: number; y: number; width: number; height: number }>(DefaultWindowState);
|
||||
|
||||
const ref = useRef({ windows, onChange });
|
||||
ref.current = { windows, onChange };
|
||||
|
||||
const refPrevSize = useRef({ w: 0, h: 0 });
|
||||
const ref = useRef({ windows, viewPort, onChange });
|
||||
ref.current = { windows, viewPort, onChange };
|
||||
|
||||
const onDebouncedChange = useMemo(() => {
|
||||
return debounce(() => {
|
||||
ref.current.onChange?.(ref.current.windows);
|
||||
ref.current.onChange?.({
|
||||
windows: ref.current.windows,
|
||||
viewPort: refPrevSize.current,
|
||||
});
|
||||
}, 20);
|
||||
}, []);
|
||||
|
||||
@@ -336,7 +355,7 @@ export const WindowManager: React.FC<WindowManagerProps> = ({ windows: initialWi
|
||||
|
||||
// Handle resize of the container and reposition windows
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
if (ref.current.viewPort == null && containerRef.current) {
|
||||
refPrevSize.current = { w: containerRef.current.clientWidth, h: containerRef.current.clientHeight };
|
||||
}
|
||||
|
||||
@@ -384,6 +403,14 @@ export const WindowManager: React.FC<WindowManagerProps> = ({ windows: initialWi
|
||||
next.position.y = container.clientHeight - next.size.height - SNAP_GAP;
|
||||
}
|
||||
|
||||
if (next.position.y < SNAP_GAP) {
|
||||
next.position.y = 0;
|
||||
}
|
||||
|
||||
if (next.position.x < SNAP_GAP) {
|
||||
next.position.x = SNAP_GAP;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,3 +65,77 @@ export const REGIONS_MAP: Record<number, Spaces> = {
|
||||
[Regions.TashMurkon]: Spaces.Amarr,
|
||||
[Regions.VergeVendor]: Spaces.Gallente,
|
||||
};
|
||||
|
||||
export type K162Type = {
|
||||
label: string;
|
||||
value: string;
|
||||
whClassName: string;
|
||||
};
|
||||
|
||||
export const K162_TYPES: K162Type[] = [
|
||||
{
|
||||
label: 'Hi-Sec',
|
||||
value: 'hs',
|
||||
whClassName: 'A641',
|
||||
},
|
||||
{
|
||||
label: 'Low-Sec',
|
||||
value: 'ls',
|
||||
whClassName: 'J377',
|
||||
},
|
||||
{
|
||||
label: 'Null-Sec',
|
||||
value: 'ns',
|
||||
whClassName: 'C248',
|
||||
},
|
||||
{
|
||||
label: 'C1',
|
||||
value: 'c1',
|
||||
whClassName: 'E004',
|
||||
},
|
||||
{
|
||||
label: 'C2',
|
||||
value: 'c2',
|
||||
whClassName: 'D382',
|
||||
},
|
||||
{
|
||||
label: 'C3',
|
||||
value: 'c3',
|
||||
whClassName: 'L477',
|
||||
},
|
||||
{
|
||||
label: 'C4',
|
||||
value: 'c4',
|
||||
whClassName: 'M001',
|
||||
},
|
||||
{
|
||||
label: 'C5',
|
||||
value: 'c5',
|
||||
whClassName: 'L614',
|
||||
},
|
||||
{
|
||||
label: 'C6',
|
||||
value: 'c6',
|
||||
whClassName: 'G008',
|
||||
},
|
||||
{
|
||||
label: 'C13',
|
||||
value: 'c13',
|
||||
whClassName: 'A009',
|
||||
},
|
||||
{
|
||||
label: 'Thera',
|
||||
value: 'thera',
|
||||
whClassName: 'F353',
|
||||
},
|
||||
{
|
||||
label: 'Pochven',
|
||||
value: 'pochven',
|
||||
whClassName: 'F216',
|
||||
},
|
||||
];
|
||||
|
||||
export const K162_TYPES_MAP: { [key: string]: K162Type } = K162_TYPES.reduce(
|
||||
(acc, x) => ({ ...acc, [x.value]: x }),
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SignatureCustomInfo } from '@/hooks/Mapper/types';
|
||||
|
||||
export const parseSignatureCustomInfo = (str: string | undefined): SignatureCustomInfo => {
|
||||
if (str == null || str === '') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return JSON.parse(str);
|
||||
};
|
||||
@@ -1,21 +1,27 @@
|
||||
import { ContextStoreDataUpdate, useContextStore } from '@/hooks/Mapper/utils';
|
||||
import { createContext, Dispatch, ForwardedRef, forwardRef, SetStateAction, useContext, useEffect } from 'react';
|
||||
import { MapUnionTypes, OutCommandHandler, SolarSystemConnection } from '@/hooks/Mapper/types';
|
||||
import {
|
||||
CommandLinkSignatureToSystem,
|
||||
MapUnionTypes,
|
||||
OutCommandHandler,
|
||||
SolarSystemConnection,
|
||||
} from '@/hooks/Mapper/types';
|
||||
import { useMapRootHandlers } from '@/hooks/Mapper/mapRootProvider/hooks';
|
||||
import { WithChildren } from '@/hooks/Mapper/types/common.ts';
|
||||
import useLocalStorageState from 'use-local-storage-state';
|
||||
import {
|
||||
ToggleWidgetVisibility,
|
||||
UpdateWidgetSettingsFunc,
|
||||
useStoreWidgets,
|
||||
WindowStoreInfo,
|
||||
} from '@/hooks/Mapper/mapRootProvider/hooks/useStoreWidgets.ts';
|
||||
import { CommandLinkSignatureToSystem } from '@/hooks/Mapper/types';
|
||||
import { WindowsManagerOnChange } from '@/hooks/Mapper/components/ui-kit/WindowManager';
|
||||
import { DetailedKill } from '../types/kills';
|
||||
|
||||
export type MapRootData = MapUnionTypes & {
|
||||
selectedSystems: string[];
|
||||
selectedConnections: Pick<SolarSystemConnection, 'source' | 'target'>[];
|
||||
linkSignatureToSystem: CommandLinkSignatureToSystem | null;
|
||||
detailedKills: Record<string, DetailedKill[]>;
|
||||
};
|
||||
|
||||
const INITIAL_DATA: MapRootData = {
|
||||
@@ -31,7 +37,7 @@ const INITIAL_DATA: MapRootData = {
|
||||
routes: undefined,
|
||||
kills: [],
|
||||
connections: [],
|
||||
|
||||
detailedKills: {},
|
||||
selectedSystems: [],
|
||||
selectedConnections: [],
|
||||
userPermissions: {},
|
||||
@@ -80,7 +86,7 @@ export interface MapRootContextProps {
|
||||
setInterfaceSettings: Dispatch<SetStateAction<InterfaceStoredSettings>>;
|
||||
windowsSettings: WindowStoreInfo;
|
||||
toggleWidgetVisibility: ToggleWidgetVisibility;
|
||||
updateWidgetSettings: UpdateWidgetSettingsFunc;
|
||||
updateWidgetSettings: WindowsManagerOnChange;
|
||||
resetWidgets: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,17 +10,18 @@ import { useLoadSystemStatic } from '@/hooks/Mapper/mapRootProvider/hooks/useLoa
|
||||
import { OutCommand } from '@/hooks/Mapper/types/mapHandlers.ts';
|
||||
import { emitMapEvent } from '@/hooks/Mapper/events';
|
||||
import { Commands } from '@/hooks/Mapper/types/mapHandlers.ts';
|
||||
import { DetailedKill } from '@/hooks/Mapper/types/kills';
|
||||
export const useCommandsSystems = () => {
|
||||
const {
|
||||
update,
|
||||
data: { systems, systemSignatures },
|
||||
data: { systems, systemSignatures, detailedKills },
|
||||
outCommand,
|
||||
} = useMapRootState();
|
||||
|
||||
const { addSystemStatic } = useLoadSystemStatic({ systems: [] });
|
||||
|
||||
const ref = useRef({ systems, systemSignatures, update, addSystemStatic });
|
||||
ref.current = { systems, systemSignatures, update, addSystemStatic };
|
||||
const ref = useRef({ systems, systemSignatures, update, addSystemStatic, detailedKills });
|
||||
ref.current = { systems, systemSignatures, update, addSystemStatic, detailedKills };
|
||||
|
||||
const addSystems = useCallback((systemsToAdd: CommandAddSystems) => {
|
||||
const { update, addSystemStatic, systems } = ref.current;
|
||||
@@ -84,5 +85,23 @@ export const useCommandsSystems = () => {
|
||||
update({ linkSignatureToSystem: command }, true);
|
||||
}, []);
|
||||
|
||||
return { addSystems, removeSystems, updateSystems, updateSystemSignatures, updateLinkSignatureToSystem };
|
||||
const updateDetailedKills = useCallback((newKillsMap: Record<string, DetailedKill[]>) => {
|
||||
const { update, detailedKills } = ref.current;
|
||||
|
||||
const updated = { ...detailedKills };
|
||||
for (const [systemId, killsArr] of Object.entries(newKillsMap)) {
|
||||
updated[systemId] = killsArr;
|
||||
}
|
||||
|
||||
update({ detailedKills: updated }, true);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
addSystems,
|
||||
removeSystems,
|
||||
updateSystems,
|
||||
updateSystemSignatures,
|
||||
updateLinkSignatureToSystem,
|
||||
updateDetailedKills,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CommandCharactersUpdated,
|
||||
CommandCharacterUpdated,
|
||||
CommandInit,
|
||||
CommandLinkSignatureToSystem,
|
||||
CommandMapUpdated,
|
||||
CommandPresentCharacters,
|
||||
CommandRemoveConnections,
|
||||
@@ -29,11 +30,18 @@ import {
|
||||
} from './api';
|
||||
|
||||
import { emitMapEvent } from '@/hooks/Mapper/events';
|
||||
import { DetailedKill } from '../../types/kills';
|
||||
|
||||
export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
|
||||
const mapInit = useMapInit();
|
||||
const { addSystems, removeSystems, updateSystems, updateSystemSignatures, updateLinkSignatureToSystem } =
|
||||
useCommandsSystems();
|
||||
const {
|
||||
addSystems,
|
||||
removeSystems,
|
||||
updateSystems,
|
||||
updateSystemSignatures,
|
||||
updateLinkSignatureToSystem,
|
||||
updateDetailedKills,
|
||||
} = useCommandsSystems();
|
||||
const { addConnections, removeConnections, updateConnection } = useCommandsConnections();
|
||||
const { charactersUpdated, characterAdded, characterRemoved, characterUpdated, presentCharacters } =
|
||||
useCommandsCharacters();
|
||||
@@ -111,6 +119,10 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
|
||||
// do nothing here
|
||||
break;
|
||||
|
||||
case Commands.detailedKillsUpdated:
|
||||
updateDetailedKills(data as Record<string, DetailedKill[]>);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`JOipP Interface handlers: Unknown command: ${type}`, data);
|
||||
break;
|
||||
|
||||
@@ -8,13 +8,14 @@ import {
|
||||
} from '@/hooks/Mapper/components/mapInterface/constants.tsx';
|
||||
import { WindowProps } from '@/hooks/Mapper/components/ui-kit/WindowManager/types.ts';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { SNAP_GAP } from '@/hooks/Mapper/components/ui-kit/WindowManager';
|
||||
import { SNAP_GAP, WindowsManagerOnChange } from '@/hooks/Mapper/components/ui-kit/WindowManager';
|
||||
|
||||
export type StoredWindowProps = Omit<WindowProps, 'content'>;
|
||||
export type WindowStoreInfo = {
|
||||
version: number;
|
||||
windows: StoredWindowProps[];
|
||||
visible: WidgetsIds[];
|
||||
viewPort?: { w: number; h: number } | undefined;
|
||||
};
|
||||
export type UpdateWidgetSettingsFunc = (widgets: WindowProps[]) => void;
|
||||
export type ToggleWidgetVisibility = (widgetId: WidgetsIds) => void;
|
||||
@@ -33,7 +34,7 @@ export const useStoreWidgets = () => {
|
||||
const ref = useRef({ windowsSettings, setWindowsSettings });
|
||||
ref.current = { windowsSettings, setWindowsSettings };
|
||||
|
||||
const updateWidgetSettings: UpdateWidgetSettingsFunc = useCallback(newWindows => {
|
||||
const updateWidgetSettings: WindowsManagerOnChange = useCallback(({ windows, viewPort }) => {
|
||||
const { setWindowsSettings } = ref.current;
|
||||
|
||||
setWindowsSettings(({ version, visible /*, windows*/ }: WindowStoreInfo) => {
|
||||
@@ -41,13 +42,14 @@ export const useStoreWidgets = () => {
|
||||
version,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
windows: DEFAULT_WIDGETS.map(({ content, ...x }) => {
|
||||
const windowProp = newWindows.find(j => j.id === x.id);
|
||||
const windowProp = windows.find(j => j.id === x.id);
|
||||
if (windowProp) {
|
||||
return windowProp;
|
||||
}
|
||||
|
||||
return x;
|
||||
}),
|
||||
viewPort,
|
||||
visible,
|
||||
};
|
||||
});
|
||||
@@ -92,7 +94,7 @@ export const useStoreWidgets = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { version, windows, visible } = JSON.parse(raw) as WindowStoreInfo;
|
||||
const { version, windows, visible, viewPort } = JSON.parse(raw) as WindowStoreInfo;
|
||||
if (!version || CURRENT_WINDOWS_VERSION > version) {
|
||||
setWindowsSettings(getDefaultWidgetProps());
|
||||
}
|
||||
@@ -104,6 +106,7 @@ export const useStoreWidgets = () => {
|
||||
version: CURRENT_WINDOWS_VERSION,
|
||||
windows: out as WindowProps[],
|
||||
visible,
|
||||
viewPort,
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -2,3 +2,38 @@ export type Kill = {
|
||||
solar_system_id: number;
|
||||
kills: number;
|
||||
};
|
||||
|
||||
export interface DetailedKill {
|
||||
killmail_id: number;
|
||||
solar_system_id: number;
|
||||
kill_time?: string;
|
||||
|
||||
zkb?: Record<string, unknown>;
|
||||
|
||||
victim_char_id?: number | null;
|
||||
victim_char_name?: string;
|
||||
victim_corp_id?: number | null;
|
||||
victim_corp_ticker?: string;
|
||||
victim_corp_name?: string;
|
||||
victim_alliance_id?: number | null;
|
||||
victim_alliance_ticker?: string;
|
||||
victim_alliance_name?: string;
|
||||
victim_ship_type_id?: number | null;
|
||||
victim_ship_name?: string;
|
||||
|
||||
|
||||
final_blow_char_id?: number | null;
|
||||
final_blow_char_name?: string;
|
||||
final_blow_corp_id?: number | null;
|
||||
final_blow_corp_ticker?: string;
|
||||
final_blow_corp_name?: string;
|
||||
final_blow_alliance_id?: number | null;
|
||||
final_blow_alliance_ticker?: string;
|
||||
final_blow_alliance_name?: string;
|
||||
final_blow_ship_type_id?: number | null;
|
||||
final_blow_ship_name?: string;
|
||||
|
||||
attacker_count?: number | null;
|
||||
total_value?: number | null;
|
||||
npc?: boolean;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { SolarSystemConnection } from '@/hooks/Mapper/types/connection.ts';
|
||||
import { WormholeDataRaw } from '@/hooks/Mapper/types/wormholes.ts';
|
||||
import { CharacterTypeRaw } from '@/hooks/Mapper/types/character.ts';
|
||||
import { RoutesList } from '@/hooks/Mapper/types/routes.ts';
|
||||
import { Kill } from '@/hooks/Mapper/types/kills.ts';
|
||||
import { UserPermissions } from '@/hooks/Mapper/types';
|
||||
import { DetailedKill, Kill } from '@/hooks/Mapper/types/kills.ts';
|
||||
import { SignatureGroup, UserPermissions } from '@/hooks/Mapper/types';
|
||||
|
||||
export enum Commands {
|
||||
init = 'init',
|
||||
@@ -21,6 +21,7 @@ export enum Commands {
|
||||
updateConnection = 'update_connection',
|
||||
mapUpdated = 'map_updated',
|
||||
killsUpdated = 'kills_updated',
|
||||
detailedKillsUpdated = 'detailed_kills_updated',
|
||||
routes = 'routes',
|
||||
centerSystem = 'center_system',
|
||||
selectSystem = 'select_system',
|
||||
@@ -43,6 +44,7 @@ export type Command =
|
||||
| Commands.updateConnection
|
||||
| Commands.mapUpdated
|
||||
| Commands.killsUpdated
|
||||
| Commands.detailedKillsUpdated
|
||||
| Commands.routes
|
||||
| Commands.selectSystem
|
||||
| Commands.centerSystem
|
||||
@@ -76,9 +78,11 @@ export type CommandCharacterRemoved = CharacterTypeRaw;
|
||||
export type CommandCharacterUpdated = CharacterTypeRaw;
|
||||
export type CommandPresentCharacters = string[];
|
||||
export type CommandUpdateConnection = SolarSystemConnection;
|
||||
export type CommandSignaturesUpdated = string;
|
||||
export type CommandMapUpdated = Partial<CommandInit>;
|
||||
export type CommandRoutes = RoutesList;
|
||||
export type CommandKillsUpdated = Kill[];
|
||||
export type CommandDetailedKillsUpdated = Record<string, DetailedKill[]>;
|
||||
export type CommandSelectSystem = string | undefined;
|
||||
export type CommandCenterSystem = string | undefined;
|
||||
export type CommandLinkSignatureToSystem = {
|
||||
@@ -103,6 +107,7 @@ export interface CommandData {
|
||||
[Commands.mapUpdated]: CommandMapUpdated;
|
||||
[Commands.routes]: CommandRoutes;
|
||||
[Commands.killsUpdated]: CommandKillsUpdated;
|
||||
[Commands.detailedKillsUpdated]: CommandDetailedKillsUpdated;
|
||||
[Commands.selectSystem]: CommandSelectSystem;
|
||||
[Commands.centerSystem]: CommandCenterSystem;
|
||||
[Commands.linkSignatureToSystem]: CommandLinkSignatureToSystem;
|
||||
@@ -151,6 +156,8 @@ export enum OutCommand {
|
||||
linkSignatureToSystem = 'link_signature_to_system',
|
||||
getCorporationNames = 'get_corporation_names',
|
||||
getCorporationTicker = 'get_corporation_ticker',
|
||||
getSystemKills = 'get_system_kills',
|
||||
getSystemsKills = 'get_systems_kills',
|
||||
|
||||
// Only UI commands
|
||||
openSettings = 'open_settings',
|
||||
@@ -161,4 +168,5 @@ export enum OutCommand {
|
||||
searchSystems = 'search_systems',
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type OutCommandHandler = <T = any>(event: { type: OutCommand; data: any }) => Promise<T>;
|
||||
|
||||
@@ -26,15 +26,20 @@ export type GroupType = {
|
||||
h: number;
|
||||
};
|
||||
|
||||
export type SignatureCustomInfo = {
|
||||
k162Type?: string;
|
||||
isEOL?: boolean;
|
||||
};
|
||||
|
||||
export type SystemSignature = {
|
||||
eve_id: string;
|
||||
kind: SignatureKind;
|
||||
name: string;
|
||||
// SignatureCustomInfo
|
||||
custom_info?: string;
|
||||
description?: string;
|
||||
group: SignatureGroup;
|
||||
type: string;
|
||||
k162Type?: string;
|
||||
linked_system?: SolarSystemStaticInfoRaw;
|
||||
inserted_at?: string;
|
||||
updated_at?: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { XYPosition } from 'reactflow';
|
||||
|
||||
import { SystemSignature } from '@/hooks/Mapper/types/signatures';
|
||||
import { DetailedKill } from './kills';
|
||||
|
||||
export enum SolarSystemStaticInfoRawNames {
|
||||
regionId = 'region_id',
|
||||
|
||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
BIN
assets/static/images/news/01-27-zkill-widget/cover.png
Executable file
BIN
assets/static/images/news/01-27-zkill-widget/cover.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 409 KiB |
BIN
assets/static/images/news/01-27-zkill-widget/enable-zkill.png
Executable file
BIN
assets/static/images/news/01-27-zkill-widget/enable-zkill.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
@@ -53,6 +53,11 @@ public_api_disabled =
|
||||
|> get_var_from_path_or_env("WANDERER_PUBLIC_API_DISABLED", "false")
|
||||
|> String.to_existing_atom()
|
||||
|
||||
zkill_preload_disabled =
|
||||
config_dir
|
||||
|> get_var_from_path_or_env("WANDERER_ZKILL_PRELOAD_DISABLED", "false")
|
||||
|> String.to_existing_atom()
|
||||
|
||||
map_subscriptions_enabled =
|
||||
config_dir
|
||||
|> get_var_from_path_or_env("WANDERER_MAP_SUBSCRIPTIONS_ENABLED", "false")
|
||||
@@ -113,6 +118,7 @@ config :wanderer_app,
|
||||
corp_id: System.get_env("WANDERER_CORP_ID", "-1") |> String.to_integer(),
|
||||
corp_wallet: System.get_env("WANDERER_CORP_WALLET", ""),
|
||||
public_api_disabled: public_api_disabled,
|
||||
zkill_preload_disabled: zkill_preload_disabled,
|
||||
map_subscriptions_enabled: map_subscriptions_enabled,
|
||||
map_connection_auto_expire_hours: map_connection_auto_expire_hours,
|
||||
map_connection_auto_eol_hours: map_connection_auto_eol_hours,
|
||||
|
||||
@@ -127,7 +127,6 @@ defmodule WandererApp.Api.Map do
|
||||
update :update_api_key do
|
||||
accept [:public_api_key]
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
attributes do
|
||||
|
||||
@@ -13,39 +13,48 @@ defmodule WandererApp.Application do
|
||||
WandererAppWeb.Telemetry,
|
||||
WandererApp.Vault,
|
||||
WandererApp.Repo,
|
||||
|
||||
{Phoenix.PubSub, name: WandererApp.PubSub, adapter_name: Phoenix.PubSub.PG2},
|
||||
{Finch, name: WandererApp.Finch},
|
||||
|
||||
{
|
||||
Finch,
|
||||
name: WandererApp.Finch,
|
||||
pools: %{
|
||||
default: [
|
||||
size: 25, # number of connections per pool
|
||||
count: 2, # number of pools (so total 50 connections)
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
WandererApp.Cache,
|
||||
Supervisor.child_spec({Cachex, name: :system_static_info_cache},
|
||||
id: :system_static_info_cache_worker
|
||||
),
|
||||
Supervisor.child_spec({Cachex, name: :ship_types_cache},
|
||||
id: :ship_types_cache_worker
|
||||
),
|
||||
Supervisor.child_spec({Cachex, name: :character_cache}, id: :character_cache_worker),
|
||||
Supervisor.child_spec({Cachex, name: :map_cache}, id: :map_cache_worker),
|
||||
Supervisor.child_spec({Cachex, name: :character_state_cache},
|
||||
id: :character_state_cache_worker
|
||||
),
|
||||
|
||||
Supervisor.child_spec({Cachex, name: :system_static_info_cache}, id: :system_static_info_cache_worker),
|
||||
Supervisor.child_spec({Cachex, name: :ship_types_cache}, id: :ship_types_cache_worker),
|
||||
Supervisor.child_spec({Cachex, name: :character_cache}, id: :character_cache_worker),
|
||||
Supervisor.child_spec({Cachex, name: :map_cache}, id: :map_cache_worker),
|
||||
Supervisor.child_spec({Cachex, name: :character_state_cache}, id: :character_state_cache_worker),
|
||||
|
||||
WandererApp.Scheduler,
|
||||
|
||||
{Registry, keys: :unique, name: WandererApp.MapRegistry},
|
||||
{Registry, keys: :unique, name: WandererApp.Character.TrackerRegistry},
|
||||
{PartitionSupervisor,
|
||||
child_spec: DynamicSupervisor, name: WandererApp.Map.DynamicSupervisors},
|
||||
{PartitionSupervisor,
|
||||
child_spec: DynamicSupervisor, name: WandererApp.Character.DynamicSupervisors},
|
||||
|
||||
{PartitionSupervisor, child_spec: DynamicSupervisor, name: WandererApp.Map.DynamicSupervisors},
|
||||
{PartitionSupervisor, child_spec: DynamicSupervisor, name: WandererApp.Character.DynamicSupervisors},
|
||||
|
||||
WandererApp.Zkb.Supervisor,
|
||||
WandererApp.Server.ServerStatusTracker,
|
||||
WandererApp.Server.TheraDataFetcher,
|
||||
WandererApp.Character.TrackerManager,
|
||||
WandererApp.Map.Manager,
|
||||
WandererApp.Map.ZkbDataFetcher,
|
||||
|
||||
WandererAppWeb.Presence,
|
||||
WandererAppWeb.Endpoint
|
||||
] ++ maybe_start_corp_wallet_tracker(WandererApp.Env.map_subscriptions_enabled?())
|
||||
]
|
||||
++ maybe_start_corp_wallet_tracker(WandererApp.Env.map_subscriptions_enabled?())
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
opts = [strategy: :one_for_one, name: WandererApp.Supervisor]
|
||||
|
||||
Supervisor.start_link(children, opts)
|
||||
@@ -59,8 +68,6 @@ defmodule WandererApp.Application do
|
||||
end
|
||||
end
|
||||
|
||||
# Tell Phoenix to update the endpoint configuration
|
||||
# whenever the application is updated.
|
||||
@impl true
|
||||
def config_change(changed, _new, removed) do
|
||||
WandererAppWeb.Endpoint.config_change(changed, removed)
|
||||
@@ -72,5 +79,6 @@ defmodule WandererApp.Application do
|
||||
WandererApp.StartCorpWalletTrackerTask
|
||||
]
|
||||
|
||||
defp maybe_start_corp_wallet_tracker(_), do: []
|
||||
defp maybe_start_corp_wallet_tracker(_),
|
||||
do: []
|
||||
end
|
||||
|
||||
@@ -71,7 +71,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
{:ok, %{eve_id: eve_id}} = WandererApp.Character.get_character(character_id)
|
||||
|
||||
case WandererApp.Esi.get_character_info(eve_id) do
|
||||
{:ok, info} ->
|
||||
{:ok, _info} ->
|
||||
{:ok, character_state} = WandererApp.Character.get_character_state(character_id)
|
||||
|
||||
update = maybe_update_corporation(character_state, eve_id |> String.to_integer())
|
||||
|
||||
@@ -11,6 +11,7 @@ defmodule WandererApp.Env do
|
||||
def invites, do: get_key(:invites, false)
|
||||
def map_subscriptions_enabled?, do: get_key(:map_subscriptions_enabled, false)
|
||||
def public_api_disabled?, do: get_key(:public_api_disabled, false)
|
||||
def zkill_preload_disabled?, do: get_key(:zkill_preload_disabled, false)
|
||||
def wallet_tracking_enabled?, do: get_key(:wallet_tracking_enabled, false)
|
||||
def admins, do: get_key(:admins, [])
|
||||
def admin_username, do: get_key(:admin_username)
|
||||
@@ -39,4 +40,12 @@ defmodule WandererApp.Env do
|
||||
do: get_key(:map_connection_eol_expire_timeout_mins)
|
||||
|
||||
def get_key(key, default \\ nil), do: Application.get_env(@app, key, default)
|
||||
|
||||
@doc """
|
||||
A single map containing environment variables
|
||||
made available to react
|
||||
"""
|
||||
def to_client_env do
|
||||
%{detailedKillsDisabled: zkill_preload_disabled?()}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -24,6 +24,9 @@ defmodule WandererApp.Esi do
|
||||
defdelegate find_routes(map_id, origin, hubs, routes_settings), to: WandererApp.Esi.ApiClient
|
||||
defdelegate search(character_eve_id, opts \\ []), to: WandererApp.Esi.ApiClient
|
||||
|
||||
defdelegate get_killmail(killmail_id, killmail_hash, opts \\ []), to: WandererApp.Esi.ApiClient
|
||||
|
||||
|
||||
defdelegate set_autopilot_waypoint(
|
||||
add_to_beginning,
|
||||
clear_other_waypoints,
|
||||
|
||||
@@ -289,6 +289,16 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@decorate cacheable(
|
||||
cache: Cache,
|
||||
key: "killmail-#{killmail_id}-#{killmail_hash}",
|
||||
opts: [ttl: @ttl]
|
||||
)
|
||||
def get_killmail(killmail_id, killmail_hash, opts \\ []) do
|
||||
get("/killmails/#{killmail_id}/#{killmail_hash}/", _with_cache_opts(opts))
|
||||
end
|
||||
|
||||
@decorate cacheable(
|
||||
cache: Cache,
|
||||
key: "info-#{eve_id}",
|
||||
|
||||
@@ -6,114 +6,196 @@ defmodule WandererApp.Map.ZkbDataFetcher do
|
||||
|
||||
require Logger
|
||||
|
||||
alias WandererApp.Zkb.KillsProvider.KillsCache
|
||||
|
||||
@interval :timer.seconds(15)
|
||||
@store_map_kills_timeout :timer.hours(1)
|
||||
@logger Application.compile_env(:wanderer_app, :logger)
|
||||
@pubsub_client Application.compile_env(:wanderer_app, :pubsub_client)
|
||||
|
||||
# This means 120 “ticks” of 15s each → ~30 minutes
|
||||
@preload_cycle_ticks 120
|
||||
|
||||
def start_link(_) do
|
||||
GenServer.start(__MODULE__, [], name: __MODULE__)
|
||||
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init([]) do
|
||||
{:ok, timer} = :timer.send_interval(@interval, :fetch_data)
|
||||
|
||||
{:ok, %{timer: timer}}
|
||||
def init(_arg) do
|
||||
{:ok, _timer_ref} = :timer.send_interval(@interval, :fetch_data)
|
||||
{:ok, %{iteration: 0}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:fetch_data, state) do
|
||||
def handle_info(:fetch_data, %{iteration: iteration} = state) do
|
||||
WandererApp.Map.RegistryHelper.list_all_maps()
|
||||
|> Task.async_stream(
|
||||
fn %{id: map_id, pid: _server_pid} ->
|
||||
try do
|
||||
map_id
|
||||
|> WandererApp.Map.Server.map_pid()
|
||||
|> case do
|
||||
pid when is_pid(pid) ->
|
||||
_update_map_kills(map_id)
|
||||
if WandererApp.Map.Server.map_pid(map_id) do
|
||||
update_map_kills(map_id)
|
||||
|
||||
nil ->
|
||||
:ok
|
||||
unless WandererApp.Env.zkill_preload_disabled?() do
|
||||
update_detailed_map_kills(map_id)
|
||||
end
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
@logger.error(Exception.message(e))
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
max_concurrency: 10,
|
||||
on_timeout: :kill_task
|
||||
)
|
||||
|> Enum.map(fn _ -> :ok end)
|
||||
|> Enum.each(fn _ -> :ok end)
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
new_iteration = iteration + 1
|
||||
|
||||
@impl true
|
||||
def handle_info({ref, result}, state) do
|
||||
Process.demonitor(ref, [:flush])
|
||||
cond do
|
||||
WandererApp.Env.zkill_preload_disabled?() ->
|
||||
# If preload is disabled, just update iteration
|
||||
{:noreply, %{state | iteration: new_iteration}}
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
new_iteration >= @preload_cycle_ticks ->
|
||||
Logger.info("[ZkbDataFetcher] Triggering a fresh kill preload pass ...")
|
||||
WandererApp.Zkb.KillsPreloader.run_preload_now()
|
||||
{:noreply, %{state | iteration: 0}}
|
||||
|
||||
defp _update_map_kills(map_id) do
|
||||
case WandererApp.Cache.lookup!("map_#{map_id}:started", false) do
|
||||
true ->
|
||||
map_id
|
||||
|> WandererApp.Map.get_map!()
|
||||
|> Map.get(:systems, Map.new())
|
||||
|> Enum.reduce(Map.new(), fn {solar_system_id, _system}, acc ->
|
||||
kills_count = WandererApp.Cache.get("zkb_kills_#{solar_system_id}")
|
||||
acc |> Map.put(solar_system_id, kills_count || 0)
|
||||
end)
|
||||
|> _maybe_broadcast_map_kills(map_id)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
{:noreply, %{state | iteration: new_iteration}}
|
||||
end
|
||||
end
|
||||
|
||||
defp _maybe_broadcast_map_kills(new_kills_map, map_id) do
|
||||
{:ok, old_kills_map} = WandererApp.Cache.lookup("map_#{map_id}:zkb_kills", Map.new())
|
||||
# Catch any async task results we aren't explicitly pattern-matching
|
||||
@impl true
|
||||
def handle_info({ref, _result}, state) do
|
||||
Process.demonitor(ref, [:flush])
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
updated_kills_system_ids =
|
||||
new_kills_map
|
||||
|> Map.filter(fn {solar_system_id, new_kills_count} ->
|
||||
old_kills_count = old_kills_map |> Map.get(solar_system_id, 0)
|
||||
|
||||
new_kills_count != old_kills_count and
|
||||
new_kills_count > 0
|
||||
defp update_map_kills(map_id) do
|
||||
with_started_map(map_id, "basic kills update", fn ->
|
||||
map_id
|
||||
|> WandererApp.Map.get_map!()
|
||||
|> Map.get(:systems, %{})
|
||||
|> Enum.into(%{}, fn {solar_system_id, _system} ->
|
||||
kills_count = WandererApp.Cache.get("zkb_kills_#{solar_system_id}") || 0
|
||||
{solar_system_id, kills_count}
|
||||
end)
|
||||
|> Map.keys()
|
||||
|> maybe_broadcast_map_kills(map_id)
|
||||
end)
|
||||
end
|
||||
|
||||
removed_kills_system_ids =
|
||||
old_kills_map
|
||||
|> Map.filter(fn {solar_system_id, old_kills_count} ->
|
||||
new_kills_count = new_kills_map |> Map.get(solar_system_id, 0)
|
||||
defp update_detailed_map_kills(map_id) do
|
||||
with_started_map(map_id, "detailed kills update", fn ->
|
||||
systems =
|
||||
map_id
|
||||
|> WandererApp.Map.get_map!()
|
||||
|> Map.get(:systems, %{})
|
||||
|
||||
old_kills_count > 0 and new_kills_count == 0
|
||||
end)
|
||||
|> Map.keys()
|
||||
# Old cache data
|
||||
old_ids_map = WandererApp.Cache.get("map_#{map_id}:zkb_ids") || %{}
|
||||
old_details_map = WandererApp.Cache.get("map_#{map_id}:zkb_detailed_kills") || %{}
|
||||
|
||||
(updated_kills_system_ids ++ removed_kills_system_ids)
|
||||
|> case do
|
||||
[] ->
|
||||
new_ids_map =
|
||||
Enum.into(systems, %{}, fn {solar_system_id, _} ->
|
||||
ids = KillsCache.get_system_killmail_ids(solar_system_id) |> MapSet.new()
|
||||
{solar_system_id, ids}
|
||||
end)
|
||||
|
||||
changed_systems =
|
||||
new_ids_map
|
||||
|> Enum.filter(fn {system_id, new_ids_set} ->
|
||||
old_set = MapSet.new(Map.get(old_ids_map, system_id, []))
|
||||
not MapSet.equal?(new_ids_set, old_set)
|
||||
end)
|
||||
|> Enum.map(&elem(&1, 0))
|
||||
|
||||
if changed_systems == [] do
|
||||
Logger.debug("[ZkbDataFetcher] No changes in detailed kills for map_id=#{map_id}")
|
||||
:ok
|
||||
else
|
||||
# Build new details for each changed system
|
||||
updated_details_map =
|
||||
Enum.reduce(changed_systems, old_details_map, fn system_id, acc ->
|
||||
kill_ids =
|
||||
new_ids_map
|
||||
|> Map.fetch!(system_id)
|
||||
|> MapSet.to_list()
|
||||
|
||||
system_ids ->
|
||||
:ok =
|
||||
WandererApp.Cache.put("map_#{map_id}:zkb_kills", new_kills_map,
|
||||
ttl: @store_map_kills_timeout
|
||||
)
|
||||
kill_details =
|
||||
kill_ids
|
||||
|> Enum.map(&KillsCache.get_killmail/1)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
Map.put(acc, system_id, kill_details)
|
||||
end)
|
||||
|
||||
updated_ids_map =
|
||||
Enum.reduce(changed_systems, old_ids_map, fn system_id, acc ->
|
||||
new_ids_list = new_ids_map[system_id] |> MapSet.to_list()
|
||||
Map.put(acc, system_id, new_ids_list)
|
||||
end)
|
||||
|
||||
WandererApp.Cache.put("map_#{map_id}:zkb_ids", updated_ids_map,
|
||||
ttl: :timer.hours(KillsCache.killmail_ttl)
|
||||
)
|
||||
|
||||
WandererApp.Cache.put("map_#{map_id}:zkb_detailed_kills", updated_details_map,
|
||||
ttl: :timer.hours(KillsCache.killmail_ttl)
|
||||
)
|
||||
|
||||
changed_data = Map.take(updated_details_map, changed_systems)
|
||||
|
||||
@pubsub_client.broadcast!(WandererApp.PubSub, map_id, %{
|
||||
event: :kills_updated,
|
||||
payload: new_kills_map |> Map.take(system_ids)
|
||||
event: :detailed_kills_updated,
|
||||
payload: changed_data
|
||||
})
|
||||
|
||||
:ok
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp maybe_broadcast_map_kills(new_kills_map, map_id) do
|
||||
{:ok, old_kills_map} = WandererApp.Cache.lookup("map_#{map_id}:zkb_kills", %{})
|
||||
|
||||
# Use the union of keys from both the new and old maps
|
||||
all_system_ids = Map.keys(Map.merge(new_kills_map, old_kills_map))
|
||||
|
||||
changed_system_ids =
|
||||
Enum.filter(all_system_ids, fn system_id ->
|
||||
new_kills_count = Map.get(new_kills_map, system_id, 0)
|
||||
old_kills_count = Map.get(old_kills_map, system_id, 0)
|
||||
new_kills_count != old_kills_count and
|
||||
(new_kills_count > 0 or (old_kills_count > 0 and new_kills_count == 0))
|
||||
end)
|
||||
|
||||
if changed_system_ids == [] do
|
||||
:ok
|
||||
else
|
||||
:ok =
|
||||
WandererApp.Cache.put("map_#{map_id}:zkb_kills", new_kills_map,
|
||||
ttl: @store_map_kills_timeout
|
||||
)
|
||||
|
||||
payload = Map.take(new_kills_map, changed_system_ids)
|
||||
|
||||
@pubsub_client.broadcast!(WandererApp.PubSub, map_id, %{
|
||||
event: :kills_updated,
|
||||
payload: payload
|
||||
})
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp with_started_map(map_id, label \\ "operation", fun) when is_function(fun, 0) do
|
||||
if WandererApp.Cache.lookup!("map_#{map_id}:started", false) do
|
||||
fun.()
|
||||
else
|
||||
Logger.debug("[ZkbDataFetcher] Map #{map_id} not started => skipping #{label}")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
275
lib/wanderer_app/zkb/zkb_kills_preloader.ex
Normal file
275
lib/wanderer_app/zkb/zkb_kills_preloader.ex
Normal file
@@ -0,0 +1,275 @@
|
||||
defmodule WandererApp.Zkb.KillsPreloader do
|
||||
@moduledoc """
|
||||
On startup, kicks off two passes (quick and expanded) to preload kills data.
|
||||
|
||||
There is also a `run_preload_now/0` function for manual triggering of the same logic.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias WandererApp.Zkb.KillsProvider
|
||||
alias WandererApp.Zkb.KillsProvider.KillsCache
|
||||
|
||||
# ----------------
|
||||
# Configuration
|
||||
# ----------------
|
||||
|
||||
# (1) Quick pass
|
||||
@quick_limit 1
|
||||
@quick_hours 1
|
||||
|
||||
# (2) Expanded pass
|
||||
@expanded_limit 25
|
||||
@expanded_hours 24
|
||||
|
||||
# How many minutes back we look for “last active” maps
|
||||
@last_active_cutoff 30
|
||||
|
||||
# Default concurrency if not provided
|
||||
@default_max_concurrency 2
|
||||
|
||||
@doc """
|
||||
Starts the GenServer with optional opts (like `max_concurrency`).
|
||||
"""
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Public helper to explicitly request a fresh preload pass (both quick & expanded).
|
||||
"""
|
||||
def run_preload_now() do
|
||||
send(__MODULE__, :start_preload)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
state = %{
|
||||
phase: :idle,
|
||||
calls_count: 0,
|
||||
max_concurrency: Keyword.get(opts, :max_concurrency, @default_max_concurrency)
|
||||
}
|
||||
|
||||
# Kick off the preload passes once at startup
|
||||
send(self(), :start_preload)
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:start_preload, state) do
|
||||
# Gather last-active maps (or fallback).
|
||||
cutoff_time =
|
||||
DateTime.utc_now()
|
||||
|> DateTime.add(-@last_active_cutoff, :minute)
|
||||
|
||||
last_active_maps_result = WandererApp.Api.MapState.get_last_active(cutoff_time)
|
||||
last_active_maps = resolve_last_active_maps(last_active_maps_result)
|
||||
|
||||
# Gather systems from those maps
|
||||
system_tuples = gather_visible_systems(last_active_maps)
|
||||
unique_systems = Enum.uniq(system_tuples)
|
||||
|
||||
Logger.debug("""
|
||||
[KillsPreloader] Found #{length(unique_systems)} unique systems \
|
||||
across #{length(last_active_maps)} map(s)
|
||||
""")
|
||||
|
||||
# ---- QUICK PASS ----
|
||||
state_quick = %{state | phase: :quick_pass}
|
||||
|
||||
{time_quick_ms, state_after_quick} =
|
||||
measure_execution_time(fn ->
|
||||
do_pass(unique_systems, :quick, @quick_hours, @quick_limit, state_quick)
|
||||
end)
|
||||
|
||||
Logger.info("[KillsPreloader] Phase 1 (quick) done => calls_count=#{state_after_quick.calls_count}, elapsed=#{time_quick_ms}ms")
|
||||
|
||||
# ---- EXPANDED PASS ----
|
||||
state_expanded = %{state_after_quick | phase: :expanded_pass}
|
||||
|
||||
{time_expanded_ms, final_state} =
|
||||
measure_execution_time(fn ->
|
||||
do_pass(unique_systems, :expanded, @quick_hours, @expanded_limit, state_expanded)
|
||||
end)
|
||||
|
||||
Logger.info("[KillsPreloader] Phase 2 (expanded) done => calls_count=#{final_state.calls_count}, elapsed=#{time_expanded_ms}ms")
|
||||
|
||||
# Reset phase to :idle
|
||||
{:noreply, %{final_state | phase: :idle}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(_other, state), do: {:noreply, state}
|
||||
|
||||
defp resolve_last_active_maps({:ok, []}) do
|
||||
Logger.warning("[KillsPreloader] No last-active maps found. Using fallback logic...")
|
||||
|
||||
case WandererApp.Maps.get_available_maps() do
|
||||
{:ok, []} ->
|
||||
Logger.error("[KillsPreloader] Fallback: get_available_maps returned zero maps!")
|
||||
[]
|
||||
|
||||
{:ok, maps} ->
|
||||
# pick the newest map by updated_at
|
||||
fallback_map = Enum.max_by(maps, & &1.updated_at, fn -> nil end)
|
||||
if fallback_map, do: [fallback_map], else: []
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_last_active_maps({:ok, maps}) when is_list(maps),
|
||||
do: maps
|
||||
|
||||
defp resolve_last_active_maps({:error, reason}) do
|
||||
Logger.error("[KillsPreloader] Could not load last-active maps => #{inspect(reason)}")
|
||||
[]
|
||||
end
|
||||
|
||||
|
||||
defp gather_visible_systems(maps) do
|
||||
maps
|
||||
|> Enum.flat_map(fn map_record ->
|
||||
the_map_id = Map.get(map_record, :map_id) || Map.get(map_record, :id)
|
||||
|
||||
case WandererApp.MapSystemRepo.get_visible_by_map(the_map_id) do
|
||||
{:ok, systems} ->
|
||||
Enum.map(systems, fn sys -> {the_map_id, sys.solar_system_id} end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("[KillsPreloader] get_visible_by_map failed => map_id=#{inspect(the_map_id)}, reason=#{inspect(reason)}")
|
||||
[]
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
|
||||
defp do_pass(unique_systems, pass_type, hours, limit, state) do
|
||||
Logger.info("[KillsPreloader] Starting #{pass_type} pass => #{length(unique_systems)} systems")
|
||||
|
||||
{final_state, kills_map} =
|
||||
unique_systems
|
||||
|> Task.async_stream(
|
||||
fn {_map_id, system_id} ->
|
||||
fetch_kills_for_system(system_id, pass_type, hours, limit, state)
|
||||
end,
|
||||
max_concurrency: state.max_concurrency,
|
||||
timeout: pass_timeout_ms(pass_type)
|
||||
)
|
||||
|> Enum.reduce({state, %{}}, fn task_result, {acc_state, acc_map} ->
|
||||
reduce_task_result(pass_type, task_result, acc_state, acc_map)
|
||||
end)
|
||||
|
||||
if map_size(kills_map) > 0 do
|
||||
broadcast_all_kills(kills_map, pass_type)
|
||||
end
|
||||
|
||||
final_state
|
||||
end
|
||||
|
||||
defp fetch_kills_for_system(system_id, :quick, hours, limit, state) do
|
||||
Logger.debug("[KillsPreloader] Quick fetch => system=#{system_id}, hours=#{hours}, limit=#{limit}")
|
||||
|
||||
case KillsProvider.Fetcher.fetch_kills_for_system(system_id, hours, state, limit: limit, force: false) do
|
||||
{:ok, kills, updated_state} ->
|
||||
{:ok, system_id, kills, updated_state}
|
||||
|
||||
{:error, reason, updated_state} ->
|
||||
Logger.warning("[KillsPreloader] Quick fetch failed => system=#{system_id}, reason=#{inspect(reason)}")
|
||||
{:error, reason, updated_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_kills_for_system(system_id, :expanded, hours, limit, state) do
|
||||
Logger.debug("[KillsPreloader] Expanded fetch => system=#{system_id}, hours=#{hours}, limit=#{limit} (forcing refresh)")
|
||||
|
||||
with {:ok, kills_1h, updated_state} <-
|
||||
KillsProvider.Fetcher.fetch_kills_for_system(system_id, hours, state, limit: limit, force: true),
|
||||
{:ok, final_kills, final_state} <-
|
||||
maybe_fetch_more_if_needed(system_id, kills_1h, limit, updated_state) do
|
||||
{:ok, system_id, final_kills, final_state}
|
||||
else
|
||||
{:error, reason, updated_state} ->
|
||||
Logger.warning("[KillsPreloader] Expanded fetch (#{hours}h) failed => system=#{system_id}, reason=#{inspect(reason)}")
|
||||
{:error, reason, updated_state}
|
||||
end
|
||||
end
|
||||
|
||||
# If we got fewer kills than `limit` from the 1h fetch, top up from 24h
|
||||
defp maybe_fetch_more_if_needed(system_id, kills_1h, limit, state) do
|
||||
if length(kills_1h) < limit do
|
||||
needed = limit - length(kills_1h)
|
||||
Logger.debug("[KillsPreloader] Expanding to #{@expanded_hours}h => system=#{system_id}, need=#{needed} more kills")
|
||||
|
||||
case KillsProvider.Fetcher.fetch_kills_for_system(system_id, @expanded_hours, state, limit: needed, force: true) do
|
||||
{:ok, _kills_24h, updated_state2} ->
|
||||
final_kills =
|
||||
KillsCache.fetch_cached_kills(system_id)
|
||||
|> Enum.take(limit)
|
||||
|
||||
{:ok, final_kills, updated_state2}
|
||||
|
||||
{:error, reason2, updated_state2} ->
|
||||
Logger.warning("[KillsPreloader] #{@expanded_hours}h fetch failed => system=#{system_id}, reason=#{inspect(reason2)}")
|
||||
{:error, reason2, updated_state2}
|
||||
end
|
||||
else
|
||||
{:ok, kills_1h, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp reduce_task_result(pass_type, task_result, acc_state, acc_map) do
|
||||
case task_result do
|
||||
{:ok, {:ok, sys_id, kills, updated_state}} ->
|
||||
# Merge calls count from updated_state into acc_state
|
||||
new_state = merge_calls_count(acc_state, updated_state)
|
||||
new_map = Map.put(acc_map, sys_id, kills)
|
||||
{new_state, new_map}
|
||||
|
||||
{:ok, {:error, reason, updated_state}} ->
|
||||
log_failed_task(pass_type, reason)
|
||||
new_state = merge_calls_count(acc_state, updated_state)
|
||||
{new_state, acc_map}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("[KillsPreloader] #{pass_type} fetch task crashed => #{inspect(reason)}")
|
||||
{acc_state, acc_map}
|
||||
end
|
||||
end
|
||||
|
||||
defp log_failed_task(:quick, reason),
|
||||
do: Logger.warning("[KillsPreloader] Quick fetch task failed => #{inspect(reason)}")
|
||||
|
||||
defp log_failed_task(:expanded, reason),
|
||||
do: Logger.error("[KillsPreloader] Expanded fetch task failed => #{inspect(reason)}")
|
||||
|
||||
defp broadcast_all_kills(kills_map, pass_type) do
|
||||
Logger.info("[KillsPreloader] Broadcasting kills => #{map_size(kills_map)} systems (#{pass_type})")
|
||||
|
||||
Phoenix.PubSub.broadcast!(
|
||||
WandererApp.PubSub,
|
||||
"zkb_preload",
|
||||
%{
|
||||
event: :detailed_kills_updated,
|
||||
payload: kills_map,
|
||||
fetch_type: pass_type
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
defp merge_calls_count(%{calls_count: c1} = st1, %{calls_count: c2}),
|
||||
do: %{st1 | calls_count: c1 + c2}
|
||||
|
||||
defp merge_calls_count(st1, _other),
|
||||
do: st1
|
||||
|
||||
defp pass_timeout_ms(:quick), do: :timer.minutes(2)
|
||||
defp pass_timeout_ms(:expanded), do: :timer.minutes(5)
|
||||
|
||||
defp measure_execution_time(fun) when is_function(fun, 0) do
|
||||
start = System.monotonic_time()
|
||||
result = fun.()
|
||||
finish = System.monotonic_time()
|
||||
ms = System.convert_time_unit(finish - start, :native, :millisecond)
|
||||
{ms, result}
|
||||
end
|
||||
end
|
||||
@@ -1,127 +1,29 @@
|
||||
defmodule WandererApp.Zkb.KillsProvider do
|
||||
@moduledoc false
|
||||
use Fresh
|
||||
require Logger
|
||||
|
||||
alias WandererApp.Zkb.KillsProvider.Websocket
|
||||
|
||||
defstruct [:connected]
|
||||
|
||||
require Logger
|
||||
def handle_connect(status, headers, state),
|
||||
do: Websocket.handle_connect(status, headers, state)
|
||||
|
||||
@heartbeat_interval 1_000
|
||||
def handle_in(frame, state),
|
||||
do: Websocket.handle_in(frame, state)
|
||||
|
||||
def handle_connect(_status, _headers, state) do
|
||||
Logger.debug(fn ->
|
||||
"#{__MODULE__}: connected to kills stream"
|
||||
end)
|
||||
def handle_control(msg, state),
|
||||
do: Websocket.handle_control(msg, state)
|
||||
|
||||
handle_subscribe("killstream", %__MODULE__{state | connected: true})
|
||||
end
|
||||
def handle_info(msg, state),
|
||||
do: Websocket.handle_info(msg, state)
|
||||
|
||||
def handle_in({:text, frame}, state) do
|
||||
frame
|
||||
|> Jason.decode!()
|
||||
|> handle_websocket(state)
|
||||
end
|
||||
def handle_disconnect(code, reason, state),
|
||||
do: Websocket.handle_disconnect(code, reason, state)
|
||||
|
||||
def handle_control({:ping, _message}, state) do
|
||||
Process.send_after(self(), :heartbeat, @heartbeat_interval)
|
||||
{:ok, state}
|
||||
end
|
||||
def handle_error(err, state),
|
||||
do: Websocket.handle_error(err, state)
|
||||
|
||||
def handle_control(_event, state) do
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def handle_info(:heartbeat, state) do
|
||||
payload =
|
||||
Jason.encode!(%{
|
||||
"action" => "pong"
|
||||
})
|
||||
|
||||
{:reply, {:text, payload}, state}
|
||||
end
|
||||
|
||||
def handle_info(_message, state) do
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def handle_info(_message, _ws, state) do
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
defp handle_subscribe(channel, state) do
|
||||
Logger.debug(fn ->
|
||||
"#{__MODULE__} subscribe: #{inspect(channel, pretty: true)}"
|
||||
end)
|
||||
|
||||
payload =
|
||||
Jason.encode!(%{
|
||||
"action" => "sub",
|
||||
"channel" => channel
|
||||
})
|
||||
|
||||
{:reply, {:text, payload}, state}
|
||||
end
|
||||
|
||||
defp handle_websocket(message, state) do
|
||||
case message |> parse_message() do
|
||||
nil ->
|
||||
{:ok, state}
|
||||
|
||||
%{solar_system_id: solar_system_id, kill_time: kill_time} = _message ->
|
||||
case DateTime.diff(DateTime.utc_now(), kill_time, :hour) do
|
||||
0 ->
|
||||
WandererApp.Cache.incr("zkb_kills_#{solar_system_id}", 1,
|
||||
default: 0,
|
||||
ttl: :timer.hours(1)
|
||||
)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def handle_disconnect(1002, reason, _state) do
|
||||
Logger.warning(fn ->
|
||||
"Connection to socket lost by #{inspect(reason, pretty: true)}; reconnecting..."
|
||||
end)
|
||||
|
||||
:reconnect
|
||||
end
|
||||
|
||||
def handle_disconnect(_code, reason, _state) do
|
||||
Logger.warning(fn ->
|
||||
"Connection to socket lost by #{inspect(reason, pretty: true)}; closing..."
|
||||
end)
|
||||
|
||||
:reconnect
|
||||
end
|
||||
|
||||
def handle_error({error, _reason}, state)
|
||||
when error in [:encoding_failed, :casting_failed],
|
||||
do: {:ignore, state}
|
||||
|
||||
def handle_error(_error, _state), do: :reconnect
|
||||
|
||||
def handle_terminate(reason, _state) do
|
||||
Logger.warning(fn -> "Terminating client process with reason : #{inspect(reason)}" end)
|
||||
end
|
||||
|
||||
defp parse_message(
|
||||
%{
|
||||
"solar_system_id" => solar_system_id,
|
||||
"killmail_time" => killmail_time
|
||||
} = _message
|
||||
) do
|
||||
{:ok, kill_time, _} = DateTime.from_iso8601(killmail_time)
|
||||
|
||||
%{
|
||||
solar_system_id: solar_system_id,
|
||||
kill_time: kill_time
|
||||
}
|
||||
end
|
||||
|
||||
defp parse_message(_message), do: nil
|
||||
def handle_terminate(reason, state),
|
||||
do: Websocket.handle_terminate(reason, state)
|
||||
end
|
||||
|
||||
@@ -8,15 +8,27 @@ defmodule WandererApp.Zkb.Supervisor do
|
||||
end
|
||||
|
||||
def init(_init_args) do
|
||||
children = [
|
||||
{WandererApp.Zkb.KillsProvider,
|
||||
uri: "wss://zkillboard.com/websocket/",
|
||||
state: %WandererApp.Zkb.KillsProvider{},
|
||||
opts: [
|
||||
name: {:local, :zkb_kills_provider},
|
||||
mint_upgrade_opts: [Mint.WebSocket.PerMessageDeflate]
|
||||
]}
|
||||
]
|
||||
preloader_child =
|
||||
unless WandererApp.Env.zkill_preload_disabled?() do
|
||||
{WandererApp.Zkb.KillsPreloader, []}
|
||||
end
|
||||
|
||||
children =
|
||||
[
|
||||
{
|
||||
WandererApp.Zkb.KillsProvider,
|
||||
uri: "wss://zkillboard.com/websocket/",
|
||||
state: %WandererApp.Zkb.KillsProvider{
|
||||
connected: false
|
||||
},
|
||||
opts: [
|
||||
name: {:local, :zkb_kills_provider},
|
||||
mint_upgrade_opts: [Mint.WebSocket.PerMessageDeflate]
|
||||
]
|
||||
},
|
||||
preloader_child
|
||||
]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
165
lib/wanderer_app/zkb/zkills_provider/cache.ex
Normal file
165
lib/wanderer_app/zkb/zkills_provider/cache.ex
Normal file
@@ -0,0 +1,165 @@
|
||||
defmodule WandererApp.Zkb.KillsProvider.KillsCache do
|
||||
@moduledoc """
|
||||
Provides helper functions for putting/fetching kill data
|
||||
"""
|
||||
|
||||
require Logger
|
||||
alias WandererApp.Cache
|
||||
|
||||
@killmail_ttl :timer.hours(24)
|
||||
@system_kills_ttl :timer.hours(1)
|
||||
|
||||
# Base (average) expiry of 15 minutes for "recently fetched" systems
|
||||
@base_full_fetch_expiry_ms 900_000
|
||||
@jitter_percent 0.1
|
||||
|
||||
def killmail_ttl, do: @killmail_ttl
|
||||
def system_kills_ttl, do: @system_kills_ttl
|
||||
|
||||
@doc """
|
||||
Store the killmail data, keyed by killmail_id, with a 24h TTL.
|
||||
"""
|
||||
def put_killmail(killmail_id, kill_data) do
|
||||
Logger.debug("[KillsCache] Storing killmail => killmail_id=#{killmail_id}")
|
||||
Cache.put(killmail_key(killmail_id), kill_data, ttl: @killmail_ttl)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetch kills for `system_id` from the local cache only.
|
||||
Returns a list of killmail maps (could be empty).
|
||||
"""
|
||||
def fetch_cached_kills(system_id) do
|
||||
killmail_ids = get_system_killmail_ids(system_id)
|
||||
# Debug-level log for performance checks
|
||||
Logger.debug("[KillsCache] fetch_cached_kills => system_id=#{system_id}, count=#{length(killmail_ids)}")
|
||||
|
||||
killmail_ids
|
||||
|> Enum.map(&get_killmail/1)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetch cached kills for multiple solar system IDs.
|
||||
Returns a map of `%{ solar_system_id => list_of_kills }`.
|
||||
"""
|
||||
def fetch_cached_kills_for_systems(system_ids) when is_list(system_ids) do
|
||||
Enum.reduce(system_ids, %{}, fn sid, acc ->
|
||||
kills_list = fetch_cached_kills(sid)
|
||||
Map.put(acc, sid, kills_list)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetch the killmail data (if any) from the cache, by killmail_id.
|
||||
"""
|
||||
def get_killmail(killmail_id) do
|
||||
Cache.get(killmail_key(killmail_id))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds `killmail_id` to the list of killmail IDs for the system
|
||||
if it’s not already present. The TTL is 24 hours.
|
||||
"""
|
||||
def add_killmail_id_to_system_list(solar_system_id, killmail_id) do
|
||||
Cache.update(
|
||||
system_kills_list_key(solar_system_id),
|
||||
[],
|
||||
fn existing_list ->
|
||||
existing_list = existing_list || []
|
||||
if killmail_id in existing_list do
|
||||
existing_list
|
||||
else
|
||||
existing_list ++ [killmail_id]
|
||||
end
|
||||
end,
|
||||
ttl: @killmail_ttl
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a list of killmail IDs for the given system, or [] if none.
|
||||
"""
|
||||
def get_system_killmail_ids(solar_system_id) do
|
||||
Cache.get(system_kills_list_key(solar_system_id)) || []
|
||||
end
|
||||
|
||||
@doc """
|
||||
Increments the kill count for a system by `amount`. The TTL is 1 hour.
|
||||
"""
|
||||
def incr_system_kill_count(solar_system_id, amount \\ 1) do
|
||||
Cache.incr(
|
||||
system_kills_key(solar_system_id),
|
||||
amount,
|
||||
default: 0,
|
||||
ttl: @system_kills_ttl
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the integer count of kills for this system in the last hour, or 0.
|
||||
"""
|
||||
def get_system_kill_count(solar_system_id) do
|
||||
Cache.get(system_kills_key(solar_system_id)) || 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Check if the system is still in its "recently fetched" window.
|
||||
We store an `expires_at` timestamp (in ms). If `now < expires_at`,
|
||||
this system is still considered "recently fetched".
|
||||
"""
|
||||
def recently_fetched?(system_id) do
|
||||
case Cache.lookup(fetched_timestamp_key(system_id)) do
|
||||
{:ok, expires_at_ms} when is_integer(expires_at_ms) ->
|
||||
now_ms = current_time_ms()
|
||||
now_ms < expires_at_ms
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Puts a jittered `expires_at` in the cache for `system_id`,
|
||||
marking it as fully fetched for ~15 minutes (+/- 10%).
|
||||
"""
|
||||
def put_full_fetched_timestamp(system_id) do
|
||||
now_ms = current_time_ms()
|
||||
max_jitter = round(@base_full_fetch_expiry_ms * @jitter_percent)
|
||||
# random offset in range [-max_jitter..+max_jitter]
|
||||
offset = :rand.uniform(2 * max_jitter + 1) - (max_jitter + 1)
|
||||
final_expiry_ms = max(@base_full_fetch_expiry_ms + offset, 60_000)
|
||||
expires_at_ms = now_ms + final_expiry_ms
|
||||
|
||||
Logger.debug("[KillsCache] Marking system=#{system_id} recently_fetched? until #{expires_at_ms} (ms)")
|
||||
Cache.put(fetched_timestamp_key(system_id), expires_at_ms)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns how many ms remain until this system's "recently fetched" window ends.
|
||||
If it's already expired (or doesn't exist), returns -1.
|
||||
"""
|
||||
def fetch_age_ms(system_id) do
|
||||
now_ms = current_time_ms()
|
||||
|
||||
case Cache.lookup(fetched_timestamp_key(system_id)) do
|
||||
{:ok, expires_at_ms} when is_integer(expires_at_ms) ->
|
||||
if now_ms < expires_at_ms do
|
||||
expires_at_ms - now_ms
|
||||
else
|
||||
-1
|
||||
end
|
||||
|
||||
_ ->
|
||||
-1
|
||||
end
|
||||
end
|
||||
|
||||
defp killmail_key(killmail_id), do: "zkb_killmail_#{killmail_id}"
|
||||
defp system_kills_key(solar_system_id), do: "zkb_kills_#{solar_system_id}"
|
||||
defp system_kills_list_key(solar_system_id), do: "zkb_kills_list_#{solar_system_id}"
|
||||
defp fetched_timestamp_key(system_id), do: "zkb_system_fetched_at_#{system_id}"
|
||||
|
||||
defp current_time_ms() do
|
||||
DateTime.utc_now() |> DateTime.to_unix(:millisecond)
|
||||
end
|
||||
end
|
||||
211
lib/wanderer_app/zkb/zkills_provider/fetcher.ex
Normal file
211
lib/wanderer_app/zkb/zkills_provider/fetcher.ex
Normal file
@@ -0,0 +1,211 @@
|
||||
defmodule WandererApp.Zkb.KillsProvider.Fetcher do
|
||||
@moduledoc """
|
||||
Low-level API for fetching killmails from zKillboard + ESI.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
use Retry
|
||||
|
||||
alias WandererApp.Zkb.KillsProvider.{Parser, KillsCache, ZkbApi}
|
||||
|
||||
@page_size 200
|
||||
@max_pages 2
|
||||
|
||||
@doc """
|
||||
Fetch killmails for multiple systems, returning a map of system_id => kills.
|
||||
"""
|
||||
def fetch_kills_for_systems(system_ids, since_hours, state, _opts \\ []) when is_list(system_ids) do
|
||||
try do
|
||||
{final_map, final_state} =
|
||||
Enum.reduce(system_ids, {%{}, state}, fn sid, {acc_map, acc_st} ->
|
||||
case fetch_kills_for_system(sid, since_hours, acc_st) do
|
||||
{:ok, kills, new_st} ->
|
||||
{Map.put(acc_map, sid, kills), new_st}
|
||||
|
||||
{:error, reason, new_st} ->
|
||||
Logger.debug("[Fetcher] system=#{sid} => error=#{inspect(reason)}")
|
||||
{Map.put(acc_map, sid, {:error, reason}), new_st}
|
||||
end
|
||||
end)
|
||||
|
||||
Logger.debug("[Fetcher] fetch_kills_for_systems => done, final_map_size=#{map_size(final_map)} calls=#{final_state.calls_count}")
|
||||
{:ok, final_map}
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("[Fetcher] EXCEPTION in fetch_kills_for_systems => #{Exception.message(e)}")
|
||||
{:error, e}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetch killmails for a single system within `since_hours` cutoff.
|
||||
|
||||
Options:
|
||||
- `:limit` => integer limit on how many kills to fetch (optional).
|
||||
If `limit` is nil (or not set), we fetch until we exhaust pages or older kills.
|
||||
- `:force` => if true, ignore the "recently fetched" check and forcibly refetch.
|
||||
|
||||
Returns `{:ok, kills, updated_state}` on success, or `{:error, reason, updated_state}`.
|
||||
"""
|
||||
def fetch_kills_for_system(system_id, since_hours, state, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, nil)
|
||||
force? = Keyword.get(opts, :force, false)
|
||||
|
||||
log_prefix = "[Fetcher] fetch_kills_for_system => system=#{system_id}"
|
||||
|
||||
# Check the "recently fetched" cache if not forced
|
||||
if not force? and KillsCache.recently_fetched?(system_id) do
|
||||
cached_kills = KillsCache.fetch_cached_kills(system_id)
|
||||
final = maybe_take(cached_kills, limit)
|
||||
Logger.debug("#{log_prefix}, recently_fetched?=true => returning #{length(final)} cached kills")
|
||||
{:ok, final, state}
|
||||
else
|
||||
Logger.debug("#{log_prefix}, hours=#{since_hours}, limit=#{inspect(limit)}, force=#{force?}")
|
||||
|
||||
cutoff_dt = hours_ago(since_hours)
|
||||
|
||||
result =
|
||||
retry with: exponential_backoff(300)
|
||||
|> randomize()
|
||||
|> cap(5_000)
|
||||
|> expiry(120_000) do
|
||||
case do_multi_page_fetch(system_id, cutoff_dt, 1, 0, limit, state) do
|
||||
{:ok, new_st, total_fetched} ->
|
||||
# Mark system as fully fetched (to prevent repeated calls).
|
||||
KillsCache.put_full_fetched_timestamp(system_id)
|
||||
final_kills = KillsCache.fetch_cached_kills(system_id) |> maybe_take(limit)
|
||||
|
||||
Logger.debug(
|
||||
"#{log_prefix}, total_fetched=#{total_fetched}, final_cached=#{length(final_kills)}, calls_count=#{new_st.calls_count}"
|
||||
)
|
||||
|
||||
{:ok, final_kills, new_st}
|
||||
|
||||
{:error, :rate_limited, _new_st} ->
|
||||
raise ":rate_limited"
|
||||
|
||||
{:error, reason, _new_st} ->
|
||||
raise "#{log_prefix}, reason=#{inspect(reason)}"
|
||||
end
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, kills, new_st} ->
|
||||
{:ok, kills, new_st}
|
||||
|
||||
error ->
|
||||
Logger.error("#{log_prefix}, EXHAUSTED => error=#{inspect(error)}")
|
||||
{:error, error, state}
|
||||
end
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("[Fetcher] EXCEPTION in fetch_kills_for_system => #{Exception.message(e)}")
|
||||
{:error, e, state}
|
||||
end
|
||||
|
||||
defp do_multi_page_fetch(_system_id, _cutoff_dt, page, total_so_far, _limit, state)
|
||||
when page > @max_pages do
|
||||
# No more pages
|
||||
{:ok, state, total_so_far}
|
||||
end
|
||||
|
||||
defp do_multi_page_fetch(system_id, cutoff_dt, page, total_so_far, limit, state) do
|
||||
Logger.debug(
|
||||
"[Fetcher] do_multi_page_fetch => system=#{system_id}, page=#{page}, total_so_far=#{total_so_far}, limit=#{inspect(limit)}"
|
||||
)
|
||||
|
||||
with {:ok, st1} <- increment_calls_count(state),
|
||||
{:ok, st2, partials} <- ZkbApi.fetch_and_parse_page(system_id, page, st1) do
|
||||
Logger.debug("[Fetcher] system=#{system_id}, page=#{page}, partials_count=#{length(partials)}")
|
||||
|
||||
{_count_stored, older_found?, total_now} =
|
||||
Enum.reduce_while(partials, {0, false, total_so_far}, fn partial, {acc_count, had_older, acc_total} ->
|
||||
# If we have a limit and reached it, stop immediately
|
||||
if reached_limit?(limit, acc_total) do
|
||||
{:halt, {acc_count, had_older, acc_total}}
|
||||
else
|
||||
case parse_partial(partial, cutoff_dt) do
|
||||
:older ->
|
||||
# Found an older kill => we can halt the entire multi-page fetch
|
||||
{:halt, {acc_count, true, acc_total}}
|
||||
|
||||
:ok ->
|
||||
{:cont, {acc_count + 1, false, acc_total + 1}}
|
||||
|
||||
:skip ->
|
||||
{:cont, {acc_count, had_older, acc_total}}
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
cond do
|
||||
# If we found older kills, stop now
|
||||
older_found? ->
|
||||
{:ok, st2, total_now}
|
||||
|
||||
# If we have a limit and just reached or exceeded it
|
||||
reached_limit?(limit, total_now) ->
|
||||
{:ok, st2, total_now}
|
||||
|
||||
# If partials < @page_size, no more kills are left
|
||||
length(partials) < @page_size ->
|
||||
{:ok, st2, total_now}
|
||||
|
||||
# Otherwise, keep going to next page
|
||||
true ->
|
||||
do_multi_page_fetch(system_id, cutoff_dt, page + 1, total_now, limit, st2)
|
||||
end
|
||||
else
|
||||
{:error, :rate_limited, stx} ->
|
||||
{:error, :rate_limited, stx}
|
||||
|
||||
{:error, reason, stx} ->
|
||||
{:error, reason, stx}
|
||||
|
||||
other ->
|
||||
Logger.warning("[Fetcher] Unexpected result => #{inspect(other)}")
|
||||
{:error, :unexpected, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_partial(%{"killmail_id" => kill_id, "zkb" => %{"hash" => kill_hash}} = partial, cutoff_dt) do
|
||||
# If we've already cached this kill, skip
|
||||
if KillsCache.get_killmail(kill_id) do
|
||||
:skip
|
||||
else
|
||||
# Actually fetch the full kill from ESI
|
||||
case fetch_full_killmail(kill_id, kill_hash) do
|
||||
{:ok, full_km} ->
|
||||
# Delegate the time check & storing to Parser
|
||||
Parser.parse_full_and_store(full_km, partial, cutoff_dt)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("[Fetcher] ESI fail => kill_id=#{kill_id}, reason=#{inspect(reason)}")
|
||||
:skip
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_partial(_other, _cutoff_dt), do: :skip
|
||||
|
||||
defp fetch_full_killmail(k_id, k_hash) do
|
||||
case WandererApp.Esi.get_killmail(k_id, k_hash) do
|
||||
{:ok, full_km} -> {:ok, full_km}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp hours_ago(h),
|
||||
do: DateTime.utc_now() |> DateTime.add(-h * 3600, :second)
|
||||
|
||||
defp increment_calls_count(%{calls_count: c} = st),
|
||||
do: {:ok, %{st | calls_count: c + 1}}
|
||||
|
||||
defp reached_limit?(nil, _count_so_far), do: false
|
||||
defp reached_limit?(limit, count_so_far) when is_integer(limit),
|
||||
do: count_so_far >= limit
|
||||
|
||||
defp maybe_take(kills, nil), do: kills
|
||||
defp maybe_take(kills, limit), do: Enum.take(kills, limit)
|
||||
end
|
||||
327
lib/wanderer_app/zkb/zkills_provider/parser.ex
Normal file
327
lib/wanderer_app/zkb/zkills_provider/parser.ex
Normal file
@@ -0,0 +1,327 @@
|
||||
defmodule WandererApp.Zkb.KillsProvider.Parser do
|
||||
@moduledoc """
|
||||
Helper for parsing & storing a killmail from the ESI data (plus zKB partial).
|
||||
Responsible for:
|
||||
- Parsing the raw JSON structures,
|
||||
- Combining partial & full kill data,
|
||||
- Checking whether kills are 'too old',
|
||||
- Storing in KillsCache, etc.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
alias WandererApp.Zkb.KillsProvider.KillsCache
|
||||
|
||||
@doc """
|
||||
Merges the 'partial' from zKB and the 'full' killmail from ESI, checks its time
|
||||
vs. `cutoff_dt`.
|
||||
|
||||
Returns:
|
||||
- `:ok` if we parsed & stored successfully,
|
||||
- `:older` if killmail time is older than `cutoff_dt`,
|
||||
- `:skip` if we cannot parse or store for some reason.
|
||||
"""
|
||||
def parse_full_and_store(full_km, partial_zkb, cutoff_dt) when is_map(full_km) do
|
||||
# Attempt to parse the killmail_time
|
||||
case parse_killmail_time(full_km) do
|
||||
{:ok, km_dt} ->
|
||||
if older_than_cutoff?(km_dt, cutoff_dt) do
|
||||
:older
|
||||
else
|
||||
# Merge the "zkb" portion from the partial into the full killmail
|
||||
enriched = Map.merge(full_km, %{"zkb" => partial_zkb["zkb"]})
|
||||
parse_and_store_killmail(enriched)
|
||||
end
|
||||
|
||||
_ ->
|
||||
:skip
|
||||
end
|
||||
end
|
||||
|
||||
def parse_full_and_store(_full_km, _partial_zkb, _cutoff_dt),
|
||||
do: :skip
|
||||
|
||||
@doc """
|
||||
Parse a raw killmail (`full_km`) and store it if valid.
|
||||
Returns:
|
||||
- `:ok` if successfully parsed & stored,
|
||||
- `:skip` otherwise
|
||||
"""
|
||||
def parse_and_store_killmail(%{"killmail_id" => _kill_id} = full_km) do
|
||||
parsed_map = do_parse(full_km)
|
||||
|
||||
if is_nil(parsed_map) or is_nil(parsed_map["kill_time"]) do
|
||||
:skip
|
||||
else
|
||||
store_killmail(parsed_map)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
def parse_and_store_killmail(_),
|
||||
do: :skip
|
||||
|
||||
defp do_parse(%{"killmail_id" => kill_id} = km) do
|
||||
victim = Map.get(km, "victim", %{})
|
||||
attackers = Map.get(km, "attackers", [])
|
||||
|
||||
kill_time_dt =
|
||||
case DateTime.from_iso8601("#{Map.get(km, "killmail_time", "")}") do
|
||||
{:ok, dt, _off} -> dt
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
npc_flag = get_in(km, ["zkb", "npc"]) || false
|
||||
|
||||
%{
|
||||
"killmail_id" => kill_id,
|
||||
"kill_time" => kill_time_dt,
|
||||
"solar_system_id" => km["solar_system_id"],
|
||||
"zkb" => Map.get(km, "zkb", %{}),
|
||||
"attacker_count" => length(attackers),
|
||||
"total_value" => get_in(km, ["zkb", "totalValue"]) || 0,
|
||||
"victim" => victim,
|
||||
"attackers" => attackers,
|
||||
"npc" => npc_flag
|
||||
}
|
||||
end
|
||||
|
||||
defp do_parse(_),
|
||||
do: nil
|
||||
|
||||
@doc """
|
||||
Extracts & returns {:ok, DateTime} from the "killmail_time" field, or :skip on failure.
|
||||
"""
|
||||
def parse_killmail_time(full_km) do
|
||||
killmail_time_str = Map.get(full_km, "killmail_time", "")
|
||||
|
||||
case DateTime.from_iso8601(killmail_time_str) do
|
||||
{:ok, dt, _offset} ->
|
||||
{:ok, dt}
|
||||
|
||||
_ ->
|
||||
:skip
|
||||
end
|
||||
end
|
||||
|
||||
defp older_than_cutoff?(%DateTime{} = dt, %DateTime{} = cutoff_dt),
|
||||
do: DateTime.compare(dt, cutoff_dt) == :lt
|
||||
|
||||
defp store_killmail(%{"killmail_id" => nil}), do: :ok
|
||||
|
||||
defp store_killmail(%{"killmail_id" => kill_id} = parsed) do
|
||||
final = build_kill_data(parsed)
|
||||
|
||||
if final do
|
||||
enriched = maybe_enrich_killmail(final)
|
||||
KillsCache.put_killmail(kill_id, enriched)
|
||||
|
||||
system_id = enriched["solar_system_id"]
|
||||
KillsCache.add_killmail_id_to_system_list(system_id, kill_id)
|
||||
|
||||
if within_last_hour?(enriched["kill_time"]) do
|
||||
KillsCache.incr_system_kill_count(system_id)
|
||||
end
|
||||
else
|
||||
Logger.warning("[Parser] store_killmail => build_kill_data returned nil for kill_id=#{kill_id}")
|
||||
end
|
||||
end
|
||||
|
||||
defp store_killmail(_),
|
||||
do: :ok
|
||||
|
||||
defp build_kill_data(%{
|
||||
"killmail_id" => kill_id,
|
||||
"kill_time" => kill_time_dt,
|
||||
"solar_system_id" => sys_id,
|
||||
"zkb" => zkb,
|
||||
"victim" => victim,
|
||||
"attackers" => attackers,
|
||||
"attacker_count" => attacker_count,
|
||||
"total_value" => total_value,
|
||||
"npc" => npc
|
||||
}) do
|
||||
|
||||
victim_map = extract_victim_fields(victim)
|
||||
final_blow_map = extract_final_blow_fields(attackers)
|
||||
|
||||
%{
|
||||
"killmail_id" => kill_id,
|
||||
"kill_time" => kill_time_dt,
|
||||
"solar_system_id" => sys_id,
|
||||
"zkb" => zkb,
|
||||
|
||||
"victim_char_id" => victim_map.char_id,
|
||||
"victim_corp_id" => victim_map.corp_id,
|
||||
"victim_alliance_id" => victim_map.alliance_id,
|
||||
"victim_ship_type_id" => victim_map.ship_type_id,
|
||||
|
||||
"final_blow_char_id" => final_blow_map.char_id,
|
||||
"final_blow_corp_id" => final_blow_map.corp_id,
|
||||
"final_blow_alliance_id" => final_blow_map.alliance_id,
|
||||
"final_blow_ship_type_id" => final_blow_map.ship_type_id,
|
||||
|
||||
"attacker_count" => attacker_count,
|
||||
"total_value" => total_value,
|
||||
"npc" => npc
|
||||
}
|
||||
end
|
||||
|
||||
defp build_kill_data(_),
|
||||
do: nil
|
||||
|
||||
defp extract_victim_fields(%{
|
||||
"character_id" => cid,
|
||||
"corporation_id" => corp,
|
||||
"alliance_id" => alli,
|
||||
"ship_type_id" => st_id
|
||||
}),
|
||||
do: %{char_id: cid, corp_id: corp, alliance_id: alli, ship_type_id: st_id}
|
||||
|
||||
defp extract_victim_fields(%{
|
||||
"character_id" => cid,
|
||||
"corporation_id" => corp,
|
||||
"ship_type_id" => st_id
|
||||
}),
|
||||
do: %{char_id: cid, corp_id: corp, alliance_id: nil, ship_type_id: st_id}
|
||||
|
||||
defp extract_victim_fields(_),
|
||||
do: %{char_id: nil, corp_id: nil, alliance_id: nil, ship_type_id: nil}
|
||||
|
||||
defp extract_final_blow_fields(attackers) when is_list(attackers) do
|
||||
final = Enum.find(attackers, fn a -> a["final_blow"] == true end)
|
||||
extract_attacker_fields(final)
|
||||
end
|
||||
|
||||
defp extract_final_blow_fields(_),
|
||||
do: %{char_id: nil, corp_id: nil, alliance_id: nil, ship_type_id: nil}
|
||||
|
||||
defp extract_attacker_fields(nil),
|
||||
do: %{char_id: nil, corp_id: nil, alliance_id: nil, ship_type_id: nil}
|
||||
|
||||
defp extract_attacker_fields(%{
|
||||
"character_id" => cid,
|
||||
"corporation_id" => corp,
|
||||
"alliance_id" => alli,
|
||||
"ship_type_id" => st_id
|
||||
}),
|
||||
do: %{char_id: cid, corp_id: corp, alliance_id: alli, ship_type_id: st_id}
|
||||
|
||||
defp extract_attacker_fields(%{
|
||||
"character_id" => cid,
|
||||
"corporation_id" => corp,
|
||||
"ship_type_id" => st_id
|
||||
}),
|
||||
do: %{char_id: cid, corp_id: corp, alliance_id: nil, ship_type_id: st_id}
|
||||
|
||||
defp extract_attacker_fields(%{"ship_type_id" => st_id} = attacker) do
|
||||
%{
|
||||
char_id: Map.get(attacker, "character_id"),
|
||||
corp_id: Map.get(attacker, "corporation_id"),
|
||||
alliance_id: Map.get(attacker, "alliance_id"),
|
||||
ship_type_id: st_id
|
||||
}
|
||||
end
|
||||
|
||||
defp extract_attacker_fields(_),
|
||||
do: %{char_id: nil, corp_id: nil, alliance_id: nil, ship_type_id: nil}
|
||||
|
||||
defp maybe_enrich_killmail(km) do
|
||||
km
|
||||
|> enrich_victim()
|
||||
|> enrich_final_blow()
|
||||
end
|
||||
|
||||
|
||||
defp enrich_victim(km) do
|
||||
km
|
||||
|> maybe_put_character_name("victim_char_id", "victim_char_name")
|
||||
|> maybe_put_corp_info("victim_corp_id", "victim_corp_ticker", "victim_corp_name")
|
||||
|> maybe_put_alliance_info("victim_alliance_id", "victim_alliance_ticker", "victim_alliance_name")
|
||||
|> maybe_put_ship_name("victim_ship_type_id", "victim_ship_name")
|
||||
end
|
||||
|
||||
|
||||
defp enrich_final_blow(km) do
|
||||
km
|
||||
|> maybe_put_character_name("final_blow_char_id", "final_blow_char_name")
|
||||
|> maybe_put_corp_info("final_blow_corp_id", "final_blow_corp_ticker", "final_blow_corp_name")
|
||||
|> maybe_put_alliance_info("final_blow_alliance_id", "final_blow_alliance_ticker", "final_blow_alliance_name")
|
||||
|> maybe_put_ship_name("final_blow_ship_type_id", "final_blow_ship_name")
|
||||
end
|
||||
|
||||
defp maybe_put_character_name(km, id_key, name_key) do
|
||||
case Map.get(km, id_key) do
|
||||
nil -> km
|
||||
0 -> km
|
||||
eve_id ->
|
||||
case WandererApp.Esi.get_character_info(eve_id) do
|
||||
{:ok, %{"name" => char_name}} ->
|
||||
Map.put(km, name_key, char_name)
|
||||
|
||||
_ ->
|
||||
km
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_corp_info(km, id_key, ticker_key, name_key) do
|
||||
case Map.get(km, id_key) do
|
||||
nil -> km
|
||||
0 -> km
|
||||
corp_id ->
|
||||
case WandererApp.Esi.get_corporation_info(corp_id) do
|
||||
{:ok, %{"ticker" => ticker, "name" => corp_name}} ->
|
||||
km
|
||||
|> Map.put(ticker_key, ticker)
|
||||
|> Map.put(name_key, corp_name)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("[Parser] Failed to fetch corp info: ID=#{corp_id}, reason=#{inspect(reason)}")
|
||||
km
|
||||
|
||||
_ ->
|
||||
km
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_alliance_info(km, id_key, ticker_key, name_key) do
|
||||
case Map.get(km, id_key) do
|
||||
nil -> km
|
||||
0 -> km
|
||||
alliance_id ->
|
||||
case WandererApp.Esi.get_alliance_info(alliance_id) do
|
||||
{:ok, %{"ticker" => alliance_ticker, "name" => alliance_name}} ->
|
||||
km
|
||||
|> Map.put(ticker_key, alliance_ticker)
|
||||
|> Map.put(name_key, alliance_name)
|
||||
|
||||
_ ->
|
||||
km
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_ship_name(km, id_key, name_key) do
|
||||
case Map.get(km, id_key) do
|
||||
nil -> km
|
||||
0 -> km
|
||||
type_id ->
|
||||
case WandererApp.CachedInfo.get_ship_type(type_id) do
|
||||
{:ok, nil} -> km
|
||||
{:ok, %{name: ship_name}} -> Map.put(km, name_key, ship_name)
|
||||
{:error, reason} ->
|
||||
Logger.warning("[Parser] Failed to fetch ship type: ID=#{type_id}, reason=#{inspect(reason)}")
|
||||
km
|
||||
|
||||
_ -> km
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Utility
|
||||
defp within_last_hour?(nil), do: false
|
||||
|
||||
defp within_last_hour?(%DateTime{} = dt),
|
||||
do: DateTime.diff(DateTime.utc_now(), dt, :minute) < 60
|
||||
end
|
||||
86
lib/wanderer_app/zkb/zkills_provider/websocket.ex
Normal file
86
lib/wanderer_app/zkb/zkills_provider/websocket.ex
Normal file
@@ -0,0 +1,86 @@
|
||||
defmodule WandererApp.Zkb.KillsProvider.Websocket do
|
||||
@moduledoc """
|
||||
Handles real-time kills from zKillboard WebSocket.
|
||||
Always fetches from ESI to get killmail_time, victim, attackers, etc.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
alias WandererApp.Zkb.KillsProvider.Parser
|
||||
alias WandererApp.Esi
|
||||
|
||||
@heartbeat_interval 1_000
|
||||
|
||||
# Called by `KillsProvider.handle_connect`
|
||||
def handle_connect(_status, _headers, %{connected: _} = state) do
|
||||
Logger.info("[KillsProvider.Websocket] Connected => killstream")
|
||||
new_state = Map.put(state, :connected, true)
|
||||
handle_subscribe("killstream", new_state)
|
||||
end
|
||||
|
||||
# Called by `KillsProvider.handle_in`
|
||||
def handle_in({:text, frame}, state) do
|
||||
Logger.debug("[KillsProvider.Websocket] Received frame => #{frame}")
|
||||
partial = Jason.decode!(frame)
|
||||
parse_and_store_zkb_partial(partial)
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
# Called for control frames
|
||||
def handle_control({:pong, _msg}, state),
|
||||
do: {:ok, state}
|
||||
|
||||
def handle_control({:ping, _}, state) do
|
||||
Process.send_after(self(), :heartbeat, @heartbeat_interval)
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
# Called by the process mailbox
|
||||
def handle_info(:heartbeat, state) do
|
||||
payload = Jason.encode!(%{"action" => "pong"})
|
||||
{:reply, {:text, payload}, state}
|
||||
end
|
||||
|
||||
def handle_info(_other, state), do: {:ok, state}
|
||||
|
||||
# Called on disconnect
|
||||
def handle_disconnect(code, reason, _old_state) do
|
||||
Logger.warning("[KillsProvider.Websocket] Disconnected => code=#{code}, reason=#{inspect(reason)} => reconnecting")
|
||||
:reconnect
|
||||
end
|
||||
|
||||
# Called on errors
|
||||
def handle_error({err, _reason}, state) when err in [:encoding_failed, :casting_failed],
|
||||
do: {:ignore, state}
|
||||
|
||||
def handle_error(_error, _state),
|
||||
do: :reconnect
|
||||
|
||||
# Called on terminate
|
||||
def handle_terminate(reason, _state) do
|
||||
Logger.warning("[KillsProvider.Websocket] Terminating => #{inspect(reason)}")
|
||||
end
|
||||
|
||||
defp handle_subscribe(channel, state) do
|
||||
Logger.debug("[KillsProvider.Websocket] Subscribing to #{channel}")
|
||||
payload = Jason.encode!(%{"action" => "sub", "channel" => channel})
|
||||
{:reply, {:text, payload}, state}
|
||||
end
|
||||
|
||||
# The partial from zKillboard has killmail_id + zkb.hash, but no time/victim/attackers
|
||||
defp parse_and_store_zkb_partial(%{"killmail_id" => kill_id, "zkb" => %{"hash" => kill_hash}} = partial) do
|
||||
Logger.debug("[KillsProvider.Websocket] parse_and_store_zkb_partial => kill_id=#{kill_id}")
|
||||
case Esi.get_killmail(kill_id, kill_hash) do
|
||||
{:ok, full_esi_data} ->
|
||||
# Merge partial zKB fields (like totalValue) onto ESI data
|
||||
enriched = Map.merge(full_esi_data, %{"zkb" => partial["zkb"]})
|
||||
Parser.parse_and_store_killmail(enriched)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("[KillsProvider.Websocket] ESI get_killmail failed => kill_id=#{kill_id}, reason=#{inspect(reason)}")
|
||||
:skip
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_and_store_zkb_partial(_),
|
||||
do: :skip
|
||||
end
|
||||
79
lib/wanderer_app/zkb/zkills_provider/zkb_api.ex
Normal file
79
lib/wanderer_app/zkb/zkills_provider/zkb_api.ex
Normal file
@@ -0,0 +1,79 @@
|
||||
defmodule WandererApp.Zkb.KillsProvider.ZkbApi do
|
||||
@moduledoc """
|
||||
A small module for making HTTP requests to zKillboard and
|
||||
parsing JSON responses, separate from the multi-page logic.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
alias ExRated
|
||||
|
||||
# 5 calls per second allowed
|
||||
@exrated_bucket :zkb_preloader_provider
|
||||
@exrated_interval_ms 1_000
|
||||
@exrated_max_requests 5
|
||||
|
||||
@zkillboard_api "https://zkillboard.com/api"
|
||||
|
||||
@doc """
|
||||
Perform rate-limit check before fetching a single page from zKillboard and parse the response.
|
||||
|
||||
Returns:
|
||||
- `{:ok, updated_state, partials_list}` on success
|
||||
- `{:error, reason, updated_state}` if error
|
||||
"""
|
||||
def fetch_and_parse_page(system_id, page, %{calls_count: _} = state) do
|
||||
with :ok <- check_rate(),
|
||||
{:ok, resp} <- do_req_get(system_id, page),
|
||||
partials when is_list(partials) <- parse_response_body(resp) do
|
||||
{:ok, state, partials}
|
||||
else
|
||||
{:error, :rate_limited} ->
|
||||
{:error, :rate_limited, state}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason, state}
|
||||
|
||||
_other ->
|
||||
{:error, :unexpected, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_req_get(system_id, page) do
|
||||
url = "#{@zkillboard_api}/kills/systemID/#{system_id}/page/#{page}/"
|
||||
Logger.debug("[ZkbApi] GET => system=#{system_id}, page=#{page}, url=#{url}")
|
||||
|
||||
try do
|
||||
resp = Req.get!(url, decode_body: :json)
|
||||
|
||||
if resp.status == 200 do
|
||||
{:ok, resp}
|
||||
else
|
||||
{:error, {:http_status, resp.status}}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("""
|
||||
[ZkbApi] do_req_get => exception: #{Exception.message(e)}
|
||||
#{Exception.format_stacktrace(__STACKTRACE__)}
|
||||
""")
|
||||
{:error, :exception}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_response_body(%{status: 200, body: body}) when is_list(body),
|
||||
do: body
|
||||
|
||||
defp parse_response_body(_),
|
||||
do: :not_list
|
||||
|
||||
defp check_rate do
|
||||
case ExRated.check_rate(@exrated_bucket, @exrated_interval_ms, @exrated_max_requests) do
|
||||
{:ok, _count} ->
|
||||
:ok
|
||||
|
||||
{:error, limit} ->
|
||||
Logger.debug("[ZkbApi] RATE_LIMIT => limit=#{inspect(limit)}")
|
||||
{:error, :rate_limited}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -66,16 +66,12 @@ defmodule WandererAppWeb.Layouts do
|
||||
|
||||
def feedback_container(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id="feeback-container"
|
||||
data-az-l="6e9c41f4-8f3f-4e3b-bbc6-e808f9e46808"
|
||||
class={[
|
||||
"flex flex-col p-4 items-center absolute bottom-40 left-1 gap-2 tooltip tooltip-right text-gray-400 hover:text-white"
|
||||
]}
|
||||
data-tip="Leave Feedback"
|
||||
<.link
|
||||
href="https://discord.gg/cafERvDD2k"
|
||||
class="flex flex-col p-4 items-center absolute bottom-40 left-1 gap-2 tooltip tooltip-right text-gray-400 hover:text-white"
|
||||
>
|
||||
<.icon name="hero-hand-thumb-up-solid" class="h-4 w-4" />
|
||||
</div>
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
|
||||
63
lib/wanderer_app_web/controllers/common_api_controller.ex
Normal file
63
lib/wanderer_app_web/controllers/common_api_controller.ex
Normal file
@@ -0,0 +1,63 @@
|
||||
defmodule WandererAppWeb.CommonAPIController do
|
||||
use WandererAppWeb, :controller
|
||||
|
||||
alias WandererApp.CachedInfo
|
||||
alias WandererAppWeb.UtilAPIController, as: Util
|
||||
|
||||
@doc """
|
||||
GET /api/common/system_static?id=<solar_system_id>
|
||||
|
||||
Requires 'id' (the solar_system_id).
|
||||
|
||||
Example:
|
||||
GET /api/common/system_static?id=31002229
|
||||
"""
|
||||
def show_system_static(conn, params) do
|
||||
with {:ok, solar_system_str} <- Util.require_param(params, "id"),
|
||||
{:ok, solar_system_id} <- Util.parse_int(solar_system_str) do
|
||||
case CachedInfo.get_system_static_info(solar_system_id) do
|
||||
{:ok, system} ->
|
||||
data = static_system_to_json(system)
|
||||
json(conn, %{data: data})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "System not found"})
|
||||
end
|
||||
else
|
||||
{:error, msg} ->
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: msg})
|
||||
end
|
||||
end
|
||||
|
||||
# ----------------------------------------------
|
||||
# Private helpers
|
||||
# ----------------------------------------------
|
||||
|
||||
defp static_system_to_json(system) do
|
||||
system
|
||||
|> Map.take([
|
||||
:solar_system_id,
|
||||
:region_id,
|
||||
:constellation_id,
|
||||
:solar_system_name,
|
||||
:solar_system_name_lc,
|
||||
:constellation_name,
|
||||
:region_name,
|
||||
:system_class,
|
||||
:security,
|
||||
:type_description,
|
||||
:class_title,
|
||||
:is_shattered,
|
||||
:effect_name,
|
||||
:effect_power,
|
||||
:statics,
|
||||
:wandering,
|
||||
:triglavian_invasion_status,
|
||||
:sun_type_id
|
||||
])
|
||||
end
|
||||
end
|
||||
@@ -1,87 +1,48 @@
|
||||
defmodule WandererAppWeb.APIController do
|
||||
defmodule WandererAppWeb.MapAPIController do
|
||||
use WandererAppWeb, :controller
|
||||
|
||||
import Ash.Query, only: [filter: 2]
|
||||
require Logger
|
||||
|
||||
alias WandererApp.Api
|
||||
alias WandererApp.Api.Character
|
||||
alias WandererApp.MapSystemRepo
|
||||
alias WandererApp.MapCharacterSettingsRepo
|
||||
alias WandererApp.Api.Character
|
||||
alias WandererApp.CachedInfo
|
||||
|
||||
alias WandererApp.Zkb.KillsProvider.KillsCache
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Common
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
GET /api/system-static-info
|
||||
|
||||
Requires 'id' (the solar_system_id)
|
||||
|
||||
Example:
|
||||
GET /api/common/system_static?id=31002229
|
||||
GET /api/common/system_static?id=31002229
|
||||
"""
|
||||
def show_system_static(conn, params) do
|
||||
with {:ok, solar_system_str} <- require_param(params, "id"),
|
||||
{:ok, solar_system_id} <- parse_int(solar_system_str) do
|
||||
case CachedInfo.get_system_static_info(solar_system_id) do
|
||||
{:ok, system} ->
|
||||
data = static_system_to_json(system)
|
||||
json(conn, %{data: data})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "System not found"})
|
||||
end
|
||||
else
|
||||
{:error, msg} ->
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: msg})
|
||||
end
|
||||
end
|
||||
alias WandererAppWeb.UtilAPIController, as: Util
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# MAP endpoints
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
GET /api/map/systems
|
||||
|
||||
Requires either `?map_id=<UUID>` **OR** `?slug=<map-slug>` in the query params.
|
||||
|
||||
If `?all=true` is provided, **all** systems are returned.
|
||||
Otherwise, only "visible" systems are returned.
|
||||
Only "visible" systems are returned.
|
||||
|
||||
Examples:
|
||||
GET /api/map/systems?map_id=466e922b-e758-485e-9b86-afae06b88363
|
||||
GET /api/map/systems?slug=my-unique-wormhole-map
|
||||
GET /api/map/systems?map_id=<UUID>&all=true
|
||||
"""
|
||||
def list_systems(conn, params) do
|
||||
with {:ok, map_id} <- fetch_map_id(params) do
|
||||
repo_fun =
|
||||
if params["all"] == "true" do
|
||||
&MapSystemRepo.get_all_by_map/1
|
||||
else
|
||||
&MapSystemRepo.get_visible_by_map/1
|
||||
end
|
||||
|
||||
case repo_fun.(map_id) do
|
||||
{:ok, systems} ->
|
||||
data = Enum.map(systems, &map_system_to_json/1)
|
||||
json(conn, %{data: data})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Could not fetch systems for map_id=#{map_id}: #{inspect(reason)}"})
|
||||
end
|
||||
with {:ok, map_id} <- Util.fetch_map_id(params),
|
||||
{:ok, systems} <- MapSystemRepo.get_visible_by_map(map_id) do
|
||||
data = Enum.map(systems, &map_system_to_json/1)
|
||||
json(conn, %{data: data})
|
||||
else
|
||||
{:error, msg} ->
|
||||
{:error, msg} when is_binary(msg) ->
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: msg})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Could not fetch systems: #{inspect(reason)}"})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -96,29 +57,30 @@ defmodule WandererAppWeb.APIController do
|
||||
GET /api/map/system?id=31002229&slug=my-unique-wormhole-map
|
||||
"""
|
||||
def show_system(conn, params) do
|
||||
with {:ok, solar_system_str} <- require_param(params, "id"),
|
||||
{:ok, solar_system_id} <- parse_int(solar_system_str),
|
||||
{:ok, map_id} <- fetch_map_id(params) do
|
||||
case MapSystemRepo.get_by_map_and_solar_system_id(map_id, solar_system_id) do
|
||||
{:ok, system} ->
|
||||
data = map_system_to_json(system)
|
||||
json(conn, %{data: data})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "System not found in map=#{map_id}"})
|
||||
end
|
||||
with {:ok, solar_system_str} <- Util.require_param(params, "id"),
|
||||
{:ok, solar_system_id} <- Util.parse_int(solar_system_str),
|
||||
{:ok, map_id} <- Util.fetch_map_id(params),
|
||||
{:ok, system} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, solar_system_id) do
|
||||
data = map_system_to_json(system)
|
||||
json(conn, %{data: data})
|
||||
else
|
||||
{:error, msg} ->
|
||||
{:error, msg} when is_binary(msg) ->
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: msg})
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "System not found"})
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:internal_server_error)
|
||||
|> json(%{error: "Could not load system: #{inspect(reason)}"})
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
|
||||
@doc """
|
||||
GET /api/map/tracked_characters_with_info
|
||||
|
||||
@@ -129,11 +91,9 @@ defmodule WandererAppWeb.APIController do
|
||||
Returns a list of tracked records, plus their fully-loaded `character` data.
|
||||
"""
|
||||
def tracked_characters_with_info(conn, params) do
|
||||
with {:ok, map_id} <- fetch_map_id(params),
|
||||
with {:ok, map_id} <- Util.fetch_map_id(params),
|
||||
{:ok, settings_list} <- get_tracked_by_map_ids(map_id),
|
||||
{:ok, char_list} <-
|
||||
read_characters_by_ids_wrapper(Enum.map(settings_list, & &1.character_id)) do
|
||||
|
||||
{:ok, char_list} <- read_characters_by_ids_wrapper(Enum.map(settings_list, & &1.character_id)) do
|
||||
chars_by_id = Map.new(char_list, &{&1.id, &1})
|
||||
|
||||
data =
|
||||
@@ -175,8 +135,24 @@ defmodule WandererAppWeb.APIController do
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
GET /api/map/structure_timers
|
||||
|
||||
Returns structure timers for visible systems on the map
|
||||
or for a specific system if `system_id` is specified.
|
||||
|
||||
**Example usage**:
|
||||
- All visible systems:
|
||||
```
|
||||
GET /api/map/structure_timers?map_id=<uuid>
|
||||
```
|
||||
- For a single system:
|
||||
```
|
||||
GET /api/map/structure_timers?map_id=<uuid>&system_id=31002229
|
||||
```
|
||||
"""
|
||||
def show_structure_timers(conn, params) do
|
||||
with {:ok, map_id} <- fetch_map_id(params) do
|
||||
with {:ok, map_id} <- Util.fetch_map_id(params) do
|
||||
system_id_str = params["system_id"]
|
||||
|
||||
case system_id_str do
|
||||
@@ -184,7 +160,7 @@ defmodule WandererAppWeb.APIController do
|
||||
handle_all_structure_timers(conn, map_id)
|
||||
|
||||
_ ->
|
||||
case parse_int(system_id_str) do
|
||||
case Util.parse_int(system_id_str) do
|
||||
{:ok, system_id} ->
|
||||
handle_single_structure_timers(conn, map_id, system_id)
|
||||
|
||||
@@ -202,6 +178,102 @@ defmodule WandererAppWeb.APIController do
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
GET /api/map/systems_kills
|
||||
|
||||
Returns kills data for all *visible* systems on the map.
|
||||
|
||||
Requires either `?map_id=<UUID>` or `?slug=<map-slug>`.
|
||||
Optional hours_ago
|
||||
|
||||
Example:
|
||||
GET /api/map/systems_kills?map_id=<uuid>
|
||||
GET /api/map/systems_kills?slug=<map-slug>
|
||||
GET /api/map/systems_kills?map_id=<uuid>&hour_ago=<somehours>
|
||||
|
||||
"""
|
||||
def list_systems_kills(conn, params) do
|
||||
with {:ok, map_id} <- Util.fetch_map_id(params),
|
||||
# fetch visible systems from the repo
|
||||
{:ok, systems} <- MapSystemRepo.get_visible_by_map(map_id) do
|
||||
|
||||
Logger.debug(fn -> "[list_systems_kills] Found #{length(systems)} visible systems for map_id=#{map_id}" end)
|
||||
|
||||
# Parse the hours_ago param
|
||||
hours_ago = parse_hours_ago(params["hours_ago"])
|
||||
|
||||
# Gather system IDs
|
||||
solar_ids = Enum.map(systems, & &1.solar_system_id)
|
||||
|
||||
# Fetch kills for each system from the cache
|
||||
kills_map = KillsCache.fetch_cached_kills_for_systems(solar_ids)
|
||||
|
||||
# Build final JSON data
|
||||
data =
|
||||
Enum.map(systems, fn sys ->
|
||||
kills = Map.get(kills_map, sys.solar_system_id, [])
|
||||
|
||||
# Filter out kills older than hours_ago
|
||||
filtered_kills = maybe_filter_kills_by_time(kills, hours_ago)
|
||||
|
||||
Logger.debug(fn -> "
|
||||
[list_systems_kills] For system_id=#{sys.solar_system_id},
|
||||
found #{length(kills)} kills total,
|
||||
returning #{length(filtered_kills)} kills after hours_ago filter
|
||||
" end)
|
||||
|
||||
%{
|
||||
solar_system_id: sys.solar_system_id,
|
||||
kills: filtered_kills
|
||||
}
|
||||
end)
|
||||
|
||||
json(conn, %{data: data})
|
||||
else
|
||||
{:error, msg} when is_binary(msg) ->
|
||||
Logger.warn("[list_systems_kills] Bad request: #{msg}")
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: msg})
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("[list_systems_kills] Could not fetch systems: #{inspect(reason)}")
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Could not fetch systems: #{inspect(reason)}"})
|
||||
end
|
||||
end
|
||||
|
||||
# If hours_str is present and valid, parse it. Otherwise return nil (no filter).
|
||||
defp parse_hours_ago(nil), do: nil
|
||||
defp parse_hours_ago(hours_str) do
|
||||
case Integer.parse(hours_str) do
|
||||
{num, ""} when num > 0 -> num
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_filter_kills_by_time(kills, hours_ago) when is_integer(hours_ago) do
|
||||
cutoff = DateTime.utc_now() |> DateTime.add(-hours_ago * 3600, :second)
|
||||
|
||||
Enum.filter(kills, fn kill ->
|
||||
kill_time = kill["kill_time"]
|
||||
|
||||
case kill_time do
|
||||
%DateTime{} = dt ->
|
||||
# Keep kills that occurred after the cutoff
|
||||
DateTime.compare(dt, cutoff) != :lt
|
||||
|
||||
# If it's something else (nil, or a weird format), skip
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# If hours_ago is nil, maybe no time filtering:
|
||||
defp maybe_filter_kills_by_time(kills, nil), do: kills
|
||||
|
||||
defp handle_all_structure_timers(conn, map_id) do
|
||||
case MapSystemRepo.get_visible_by_map(map_id) do
|
||||
{:ok, systems} ->
|
||||
@@ -268,11 +340,8 @@ defmodule WandererAppWeb.APIController do
|
||||
|
||||
defp get_tracked_by_map_ids(map_id) do
|
||||
case MapCharacterSettingsRepo.get_tracked_by_map_all(map_id) do
|
||||
{:ok, settings_list} ->
|
||||
{:ok, settings_list}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, :get_tracked_error, reason}
|
||||
{:ok, settings_list} -> {:ok, settings_list}
|
||||
{:error, reason} -> {:error, :get_tracked_error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -286,38 +355,6 @@ defmodule WandererAppWeb.APIController do
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_map_id(%{"map_id" => mid}) when is_binary(mid) and mid != "" do
|
||||
{:ok, mid}
|
||||
end
|
||||
|
||||
defp fetch_map_id(%{"slug" => slug}) when is_binary(slug) and slug != "" do
|
||||
case WandererApp.Api.Map.get_map_by_slug(slug) do
|
||||
{:ok, map} ->
|
||||
{:ok, map.id}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:error, "No map found for slug=#{slug}"}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_map_id(_),
|
||||
do: {:error, "Must provide either ?map_id=UUID or ?slug=SLUG"}
|
||||
|
||||
defp require_param(params, key) do
|
||||
case params[key] do
|
||||
nil -> {:error, "Missing required param: #{key}"}
|
||||
"" -> {:error, "Param #{key} cannot be empty"}
|
||||
val -> {:ok, val}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_int(str) do
|
||||
case Integer.parse(str) do
|
||||
{num, ""} -> {:ok, num}
|
||||
_ -> {:error, "Invalid integer for param id=#{str}"}
|
||||
end
|
||||
end
|
||||
|
||||
defp read_characters_by_ids(ids) when is_list(ids) do
|
||||
if ids == [] do
|
||||
{:ok, []}
|
||||
@@ -366,30 +403,4 @@ defmodule WandererAppWeb.APIController do
|
||||
:updated_at
|
||||
])
|
||||
end
|
||||
|
||||
|
||||
defp static_system_to_json(system) do
|
||||
system
|
||||
|> Map.take([
|
||||
:solar_system_id,
|
||||
:region_id,
|
||||
:constellation_id,
|
||||
:solar_system_name,
|
||||
:solar_system_name_lc,
|
||||
:constellation_name,
|
||||
:region_name,
|
||||
:system_class,
|
||||
:security,
|
||||
:type_description,
|
||||
:class_title,
|
||||
:is_shattered,
|
||||
:effect_name,
|
||||
:effect_power,
|
||||
:statics,
|
||||
:wandering,
|
||||
:triglavian_invasion_status,
|
||||
:sun_type_id
|
||||
])
|
||||
end
|
||||
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
defmodule WandererAppWeb.Plugs.CheckKillsDisabled do
|
||||
import Plug.Conn
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, _opts) do
|
||||
if WandererApp.Env.zkill_preload_disabled?() do
|
||||
conn
|
||||
|> send_resp(403, "Map kill feed is disabled")
|
||||
|> halt()
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
end
|
||||
41
lib/wanderer_app_web/controllers/util_api_controller.ex
Normal file
41
lib/wanderer_app_web/controllers/util_api_controller.ex
Normal file
@@ -0,0 +1,41 @@
|
||||
defmodule WandererAppWeb.UtilAPIController do
|
||||
@moduledoc """
|
||||
Utility functions for parameter handling, fetch helpers, etc.
|
||||
"""
|
||||
|
||||
alias WandererApp.Api
|
||||
|
||||
def fetch_map_id(%{"map_id" => mid}) when is_binary(mid) and mid != "" do
|
||||
{:ok, mid}
|
||||
end
|
||||
|
||||
def fetch_map_id(%{"slug" => slug}) when is_binary(slug) and slug != "" do
|
||||
case Api.Map.get_map_by_slug(slug) do
|
||||
{:ok, map} ->
|
||||
{:ok, map.id}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:error, "No map found for slug=#{slug}"}
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_map_id(_),
|
||||
do: {:error, "Must provide either ?map_id=UUID or ?slug=SLUG"}
|
||||
|
||||
# Require a given param to be present and non-empty
|
||||
def require_param(params, key) do
|
||||
case params[key] do
|
||||
nil -> {:error, "Missing required param: #{key}"}
|
||||
"" -> {:error, "Param #{key} cannot be empty"}
|
||||
val -> {:ok, val}
|
||||
end
|
||||
end
|
||||
|
||||
# Parse a string into an integer
|
||||
def parse_int(str) do
|
||||
case Integer.parse(str) do
|
||||
{num, ""} -> {:ok, num}
|
||||
_ -> {:error, "Invalid integer for param id=#{str}"}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,160 @@
|
||||
defmodule WandererAppWeb.MapKillsEventHandler do
|
||||
@moduledoc """
|
||||
Handles kills-related UI/server events.
|
||||
"""
|
||||
|
||||
use WandererAppWeb, :live_component
|
||||
require Logger
|
||||
|
||||
alias WandererAppWeb.MapCoreEventHandler
|
||||
alias WandererApp.Zkb.KillsProvider
|
||||
alias WandererApp.Zkb.KillsProvider.KillsCache
|
||||
|
||||
|
||||
def handle_server_event(%{event: :detailed_kills_updated, payload: payload}, socket) do
|
||||
Phoenix.LiveView.push_event(socket, "detailed_kills_updated", payload)
|
||||
end
|
||||
|
||||
def handle_server_event(%{event: :fetch_system_kills_error, payload: {system_id, reason}}, socket) do
|
||||
Logger.warning("[#{__MODULE__}] fetch_kills_for_system failed for sid=#{system_id}: #{inspect(reason)}")
|
||||
socket
|
||||
end
|
||||
|
||||
def handle_server_event(%{event: :fetch_map_kills_error, payload: {map_id, reason}}, socket) do
|
||||
Logger.warning("[#{__MODULE__}] fetch_kills_for_map failed for map=#{map_id}: #{inspect(reason)}")
|
||||
socket
|
||||
end
|
||||
|
||||
def handle_server_event(%{event: :systems_kills_error, payload: {system_ids, reason}}, socket) do
|
||||
Logger.warning("[#{__MODULE__}] fetch_kills_for_systems => error=#{inspect(reason)}, systems=#{inspect(system_ids)}")
|
||||
socket
|
||||
end
|
||||
|
||||
def handle_server_event(%{event: :system_kills_error, payload: {system_id, reason}}, socket) do
|
||||
Logger.warning("[#{__MODULE__}] fetch_kills_for_system => error=#{inspect(reason)} for system=#{system_id}")
|
||||
socket
|
||||
end
|
||||
|
||||
def handle_server_event(%{event: :fetch_new_system_kills, payload: system}, socket) do
|
||||
solar_system_id = system.solar_system_id
|
||||
|
||||
Task.async(fn ->
|
||||
case KillsProvider.Fetcher.fetch_kills_for_system(solar_system_id, 24, %{calls_count: 0}) do
|
||||
{:ok, kills, _state} ->
|
||||
{:detailed_kills_updated, %{solar_system_id => kills}}
|
||||
|
||||
{:error, reason, _state} ->
|
||||
Logger.warning("[#{__MODULE__}] Failed to fetch kills for system=#{solar_system_id}: #{inspect(reason)}")
|
||||
{:fetch_system_kills_error, {solar_system_id, reason}}
|
||||
end
|
||||
end)
|
||||
|
||||
socket
|
||||
end
|
||||
|
||||
def handle_server_event(%{event: :fetch_new_map_kills, payload: %{map_id: map_id}}, socket) do
|
||||
Task.async(fn ->
|
||||
with {:ok, map_systems} <- WandererApp.MapSystemRepo.get_visible_by_map(map_id),
|
||||
system_ids <- Enum.map(map_systems, & &1.solar_system_id),
|
||||
{:ok, systems_map} <- KillsProvider.Fetcher.fetch_kills_for_systems(system_ids, 24, %{calls_count: 0}) do
|
||||
{:detailed_kills_updated, systems_map}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("[#{__MODULE__}] Failed to fetch kills for map=#{map_id}, reason=#{inspect(reason)}")
|
||||
{:fetch_map_kills_error, {map_id, reason}}
|
||||
end
|
||||
end)
|
||||
|
||||
socket
|
||||
end
|
||||
|
||||
def handle_server_event(event, socket),
|
||||
do: MapCoreEventHandler.handle_server_event(event, socket)
|
||||
|
||||
|
||||
def handle_ui_event("get_system_kills", %{"system_id" => sid, "since_hours" => sh} = payload, socket) do
|
||||
with {:ok, system_id} <- parse_id(sid),
|
||||
{:ok, since_hours} <- parse_id(sh) do
|
||||
kills_from_cache = KillsCache.fetch_cached_kills(system_id)
|
||||
reply_payload = %{"system_id" => system_id, "kills" => kills_from_cache}
|
||||
|
||||
Task.async(fn ->
|
||||
case KillsProvider.Fetcher.fetch_kills_for_system(system_id, since_hours, %{calls_count: 0}) do
|
||||
{:ok, fresh_kills, _new_state} ->
|
||||
{:detailed_kills_updated, %{system_id => fresh_kills}}
|
||||
|
||||
{:error, reason, _new_state} ->
|
||||
Logger.warning("[#{__MODULE__}] fetch_kills_for_system => error=#{inspect(reason)}")
|
||||
{:system_kills_error, {system_id, reason}}
|
||||
end
|
||||
end)
|
||||
|
||||
{:reply, reply_payload, socket}
|
||||
else
|
||||
:error ->
|
||||
Logger.warning("[#{__MODULE__}] Invalid input to get_system_kills: #{inspect(payload)}")
|
||||
{:reply, %{"error" => "invalid_input"}, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_ui_event("get_systems_kills", %{"system_ids" => sids, "since_hours" => sh} = payload, socket) do
|
||||
with {:ok, since_hours} <- parse_id(sh),
|
||||
{:ok, parsed_ids} <- parse_system_ids(sids) do
|
||||
cached_map =
|
||||
Enum.reduce(parsed_ids, %{}, fn sid, acc ->
|
||||
kills_list = KillsCache.fetch_cached_kills(sid)
|
||||
Map.put(acc, sid, kills_list)
|
||||
end)
|
||||
|
||||
reply_payload = %{"systems_kills" => cached_map}
|
||||
|
||||
Task.async(fn ->
|
||||
case KillsProvider.Fetcher.fetch_kills_for_systems(parsed_ids, since_hours, %{calls_count: 0}) do
|
||||
{:ok, systems_map} ->
|
||||
{:detailed_kills_updated, systems_map}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("[#{__MODULE__}] fetch_kills_for_systems => error=#{inspect(reason)}")
|
||||
{:systems_kills_error, {parsed_ids, reason}}
|
||||
end
|
||||
end)
|
||||
|
||||
{:reply, reply_payload, socket}
|
||||
else
|
||||
:error ->
|
||||
Logger.warning("[#{__MODULE__}] Invalid multiple-systems input: #{inspect(payload)}")
|
||||
{:reply, %{"error" => "invalid_input"}, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_ui_event(event, payload, socket) do
|
||||
MapCoreEventHandler.handle_ui_event(event, payload, socket)
|
||||
end
|
||||
|
||||
defp parse_id(value) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int, ""} -> {:ok, int}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_id(value) when is_integer(value), do: {:ok, value}
|
||||
defp parse_id(_), do: :error
|
||||
|
||||
defp parse_system_ids(ids) when is_list(ids) do
|
||||
parsed =
|
||||
Enum.reduce_while(ids, [], fn sid, acc ->
|
||||
case parse_id(sid) do
|
||||
{:ok, int_id} -> {:cont, [int_id | acc]}
|
||||
:error -> {:halt, :error}
|
||||
end
|
||||
end)
|
||||
|
||||
case parsed do
|
||||
:error -> :error
|
||||
list -> {:ok, Enum.reverse(list)}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_system_ids(_), do: :error
|
||||
end
|
||||
@@ -130,7 +130,7 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
|
||||
if delete_connection_with_sigs && not is_nil(s.linked_system_id) do
|
||||
map_id
|
||||
|> WandererApp.Map.Server.delete_connection(%{
|
||||
solar_system_source_id: solar_system_id |> String.to_integer(),
|
||||
solar_system_source_id: system.solar_system_id,
|
||||
solar_system_target_id: s.linked_system_id
|
||||
})
|
||||
end
|
||||
@@ -180,7 +180,7 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
|
||||
character_id: first_tracked_character.id,
|
||||
user_id: current_user.id,
|
||||
map_id: map_id,
|
||||
solar_system_id: solar_system_id,
|
||||
solar_system_id: system.solar_system_id,
|
||||
signatures: added_signatures_eve_ids
|
||||
})
|
||||
end
|
||||
@@ -191,7 +191,7 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
|
||||
character_id: first_tracked_character.id,
|
||||
user_id: current_user.id,
|
||||
map_id: map_id,
|
||||
solar_system_id: solar_system_id,
|
||||
solar_system_id: system.solar_system_id,
|
||||
signatures: removed_signatures_eve_ids
|
||||
})
|
||||
end
|
||||
|
||||
@@ -3,7 +3,6 @@ defmodule WandererAppWeb.MapStructuresEventHandler do
|
||||
use Phoenix.Component
|
||||
require Logger
|
||||
|
||||
alias WandererAppWeb.MapEventHandler
|
||||
alias WandererApp.Api.MapSystem
|
||||
alias WandererApp.Structure
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ defmodule WandererAppWeb.MapEventHandler do
|
||||
MapSignaturesEventHandler,
|
||||
MapSystemsEventHandler,
|
||||
MapStructuresEventHandler,
|
||||
MapKillsEventHandler
|
||||
}
|
||||
|
||||
@map_characters_events [
|
||||
@@ -105,14 +106,25 @@ defmodule WandererAppWeb.MapEventHandler do
|
||||
]
|
||||
|
||||
@map_structures_events [
|
||||
:structures_updated,
|
||||
:structures_updated
|
||||
]
|
||||
|
||||
@map_structures_ui_events [
|
||||
"update_structures",
|
||||
"get_structures",
|
||||
"get_corporation_names",
|
||||
"get_corporation_ticker",
|
||||
"get_corporation_ticker"
|
||||
]
|
||||
|
||||
@map_kills_events [
|
||||
:fetch_new_system_kills,
|
||||
:detailed_kills_updated,
|
||||
:fetch_new_map_kills
|
||||
]
|
||||
|
||||
@map_kills_ui_events [
|
||||
"get_system_kills",
|
||||
"get_systems_kills"
|
||||
]
|
||||
|
||||
def handle_event(socket, %{event: event_name} = event)
|
||||
@@ -136,13 +148,17 @@ defmodule WandererAppWeb.MapEventHandler do
|
||||
do: MapRoutesEventHandler.handle_server_event(event, socket)
|
||||
|
||||
def handle_event(socket, %{event: event_name} = event)
|
||||
when event_name in @map_structures_events,
|
||||
do: MapSignaturesEventHandler.handle_server_event(event, socket)
|
||||
when event_name in @map_structures_events,
|
||||
do: MapSignaturesEventHandler.handle_server_event(event, socket)
|
||||
|
||||
def handle_event(socket, %{event: event_name} = event)
|
||||
when event_name in @map_signatures_events,
|
||||
do: MapSignaturesEventHandler.handle_server_event(event, socket)
|
||||
|
||||
def handle_event(socket, %{event: event_name} = event)
|
||||
when event_name in @map_kills_events,
|
||||
do: MapKillsEventHandler.handle_server_event(event, socket)
|
||||
|
||||
def handle_event(socket, {ref, result}) when is_reference(ref) do
|
||||
Process.demonitor(ref, [:flush])
|
||||
|
||||
@@ -154,10 +170,7 @@ defmodule WandererAppWeb.MapEventHandler do
|
||||
{event, payload} ->
|
||||
Process.send_after(
|
||||
self(),
|
||||
%{
|
||||
event: event,
|
||||
payload: payload
|
||||
},
|
||||
%{event: event, payload: payload},
|
||||
10
|
||||
)
|
||||
|
||||
@@ -199,6 +212,10 @@ defmodule WandererAppWeb.MapEventHandler do
|
||||
when event in @map_activity_ui_events,
|
||||
do: MapActivityEventHandler.handle_ui_event(event, body, socket)
|
||||
|
||||
def handle_ui_event(event, body, socket)
|
||||
when event in @map_kills_ui_events,
|
||||
do: MapKillsEventHandler.handle_ui_event(event, body, socket)
|
||||
|
||||
def handle_ui_event(event, body, socket),
|
||||
do: MapCoreEventHandler.handle_ui_event(event, body, socket)
|
||||
|
||||
|
||||
@@ -114,33 +114,39 @@ defmodule WandererAppWeb.Router do
|
||||
plug WandererAppWeb.Plugs.CheckMapApiKey
|
||||
end
|
||||
|
||||
scope "/api/map", WandererAppWeb do
|
||||
pipe_through [:api_map]
|
||||
pipe_through [:api]
|
||||
pipeline :api_kills do
|
||||
plug WandererAppWeb.Plugs.CheckApiDisabled
|
||||
end
|
||||
|
||||
# GET /api/map/systems?map_id=... or ?slug=...
|
||||
get "/systems", APIController, :list_systems
|
||||
scope "/api/map/systems-kills", WandererAppWeb do
|
||||
pipe_through [:api, :api_map, :api_kills]
|
||||
|
||||
# GET /api/map/system-static-info?id=... plus either map_id=... or slug=...
|
||||
get "/system-static-info", APIController, :show_system_static
|
||||
get "/", MapAPIController, :list_systems_kills
|
||||
end
|
||||
|
||||
# GET /api/map/system?id=... plus either map_id=... or slug=...
|
||||
get "/system", APIController, :show_system
|
||||
scope "/api/map", WandererAppWeb do
|
||||
pipe_through [:api, :api_map]
|
||||
|
||||
# GET /api/map/characters?map_id=... or slug=...
|
||||
get "/characters", APIController, :tracked_characters_with_info
|
||||
# GET /api/map/systems?map_id=... or ?slug=...
|
||||
get "/systems", MapAPIController, :list_systems
|
||||
|
||||
# GET /api/map/structure-timers?map_id=... or slug=... and optionally ?system_id=...
|
||||
get "/structure-timers", APIController, :show_structure_timers
|
||||
end
|
||||
# GET /api/map/system?id=... plus either map_id=... or slug=...
|
||||
get "/system", MapAPIController, :show_system
|
||||
|
||||
scope "/api/common", WandererAppWeb do
|
||||
pipe_through [:api]
|
||||
# GET /api/map/characters?map_id=... or slug=...
|
||||
get "/characters", MapAPIController, :tracked_characters_with_info
|
||||
|
||||
# GET /api/common/system-static-info?id=...
|
||||
get "/system-static-info", APIController, :show_system_static
|
||||
# GET /api/map/structure-timers?map_id=... or slug=... and optionally ?system_id=...
|
||||
get "/structure-timers", MapAPIController, :show_structure_timers
|
||||
end
|
||||
|
||||
end
|
||||
scope "/api/common", WandererAppWeb do
|
||||
pipe_through [:api]
|
||||
|
||||
# GET /api/common/system-static-info?id=...
|
||||
get "/system-static-info", CommonAPIController, :show_system_static
|
||||
|
||||
end
|
||||
|
||||
scope "/", WandererAppWeb do
|
||||
pipe_through [:browser, :blog, :redirect_if_user_is_authenticated]
|
||||
|
||||
6
mix.exs
6
mix.exs
@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
|
||||
|
||||
@source_url "https://github.com/wanderer-industries/wanderer"
|
||||
|
||||
@version "1.43.2"
|
||||
@version "1.44.3"
|
||||
|
||||
def project do
|
||||
[
|
||||
@@ -34,7 +34,7 @@ defmodule WandererApp.MixProject do
|
||||
def application do
|
||||
[
|
||||
mod: {WandererApp.Application, []},
|
||||
extra_applications: [:logger, :runtime_tools]
|
||||
extra_applications: [:logger, :runtime_tools, :ex_rated]
|
||||
]
|
||||
end
|
||||
|
||||
@@ -54,6 +54,8 @@ defmodule WandererApp.MixProject do
|
||||
{:sobelow, ">= 0.0.0", only: [:dev], runtime: false},
|
||||
{:mix_audit, ">= 0.0.0", only: [:dev], runtime: false},
|
||||
{:ex_check, "~> 0.14.0", only: [:dev], runtime: false},
|
||||
{:ex_rated, "~> 2.0"},
|
||||
{:retry, "~> 0.18.0"},
|
||||
{:phoenix, "~> 1.7.12"},
|
||||
{:phoenix_ecto, "~> 4.6"},
|
||||
{:ecto_sql, "~> 3.10"},
|
||||
|
||||
2
mix.lock
2
mix.lock
@@ -37,6 +37,7 @@
|
||||
"ex2ms": {:hex, :ex2ms, "1.7.0", "45b9f523d0b777667ded60070d82d871a37e294f0b6c5b8eca86771f00f82ee1", [:mix], [], "hexpm", "2589eee51f81f1b1caa6d08c990b1ad409215fe6f64c73f73c67d36ed10be827"},
|
||||
"ex_check": {:hex, :ex_check, "0.14.0", "d6fbe0bcc51cf38fea276f5bc2af0c9ae0a2bb059f602f8de88709421dae4f0e", [:mix], [], "hexpm", "8a602e98c66e6a4be3a639321f1f545292042f290f91fa942a285888c6868af0"},
|
||||
"ex_doc": {:hex, :ex_doc, "0.34.1", "9751a0419bc15bc7580c73fde506b17b07f6402a1e5243be9e0f05a68c723368", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d441f1a86a235f59088978eff870de2e815e290e44a8bd976fe5d64470a4c9d2"},
|
||||
"ex_rated": {:hex, :ex_rated, "2.1.0", "d40e6fe35097b10222df2db7bb5dd801d57211bac65f29063de5f201c2a6aebc", [:mix], [{:ex2ms, "~> 1.5", [hex: :ex2ms, repo: "hexpm", optional: false]}], "hexpm", "936c155337253ed6474f06d941999dd3a9cf0fe767ec99a59f2d2989dc2cc13f"},
|
||||
"expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"},
|
||||
"exsync": {:hex, :exsync, "0.4.1", "0a14fe4bfcb80a509d8a0856be3dd070fffe619b9ba90fec13c58b316c176594", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "cefb22aa805ec97ffc5b75a4e1dc54bcaf781e8b32564bf74abbe5803d1b5178"},
|
||||
"file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"},
|
||||
@@ -102,6 +103,7 @@
|
||||
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
|
||||
"reactor": {:hex, :reactor, "0.10.0", "1206113c21ba69b889e072b2c189c05a7aced523b9c3cb8dbe2dab7062cb699a", [:mix], [{:igniter, "~> 0.2", [hex: :igniter, repo: "hexpm", optional: false]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4003c33e4c8b10b38897badea395e404d74d59a31beb30469a220f2b1ffe6457"},
|
||||
"req": {:hex, :req, "0.4.14", "103de133a076a31044e5458e0f850d5681eef23dfabf3ea34af63212e3b902e2", [:mix], [{:aws_signature, "~> 0.3.2", [hex: :aws_signature, repo: "hexpm", optional: true]}, {:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:nimble_ownership, "~> 0.2.0 or ~> 0.3.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "2ddd3d33f9ab714ced8d3c15fd03db40c14dbf129003c4a3eb80fac2cc0b1b08"},
|
||||
"retry": {:hex, :retry, "0.18.0", "dc58ebe22c95aa00bc2459f9e0c5400e6005541cf8539925af0aa027dc860543", [:mix], [], "hexpm", "9483959cc7bf69c9e576d9dfb2b678b71c045d3e6f39ab7c9aa1489df4492d73"},
|
||||
"rewrite": {:hex, :rewrite, "0.10.5", "6afadeae0b9d843b27ac6225e88e165884875e0aed333ef4ad3bf36f9c101bed", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "51cc347a4269ad3a1e7a2c4122dbac9198302b082f5615964358b4635ebf3d4f"},
|
||||
"site_encrypt": {:hex, :site_encrypt, "0.6.0", "9b3ae2b11723b9fa9b6fbee1d137ceaa0c245015a40c3f753a4ba1e8887986d2", [:mix], [{:bandit, "~> 0.7 or ~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.10", [hex: :jose, repo: "hexpm", optional: false]}, {:mint, "~> 1.4", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:parent, "~> 0.11", [hex: :parent, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.5", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:x509, "~> 0.8.8", [hex: :x509, repo: "hexpm", optional: false]}], "hexpm", "16e77d0bec194b9e9d95ece4b7e5b072638e1c317d3dbe58d0c26ef3635a1e33"},
|
||||
"sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"},
|
||||
|
||||
@@ -199,6 +199,101 @@ No api key is required for routes that being with /api/common
|
||||
```
|
||||
---
|
||||
|
||||
### 4. Kills Activity
|
||||
|
||||
GET /api/map/systems-kills?map_id=<UUID>
|
||||
GET /api/map/systems-kills?slug=<map-slug>"
|
||||
|
||||
- **Description:** Retrieves the kill activity for the specified map (by `map_id` or `slug`), including details on the attacker and victim
|
||||
|
||||
#### Example Request
|
||||
```
|
||||
curl -H "Authorization: Bearer <REDACTED_TOKEN>" "https://wanderer.example.com/api/map/systems-kills?slug==some-slug"
|
||||
```
|
||||
#### Example Response
|
||||
```
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"kills": [
|
||||
{
|
||||
"attacker_count": 1,
|
||||
"final_blow_alliance_id": 99013806,
|
||||
"final_blow_alliance_ticker": "TCE",
|
||||
"final_blow_char_id": 2116802670,
|
||||
"final_blow_char_name": "Bambi Bunny",
|
||||
"final_blow_corp_id": 98140648,
|
||||
"final_blow_corp_ticker": "GNK3D",
|
||||
"final_blow_ship_name": "Thrasher",
|
||||
"final_blow_ship_type_id": 16242,
|
||||
"kill_time": "2025-01-21T21:00:59Z",
|
||||
"killmail_id": 124181782,
|
||||
"npc": false,
|
||||
"solar_system_id": 30002768,
|
||||
"total_value": 10000,
|
||||
"victim_alliance_id": null,
|
||||
"victim_char_id": 2121725410,
|
||||
"victim_char_name": "Bill Drummond",
|
||||
"victim_corp_id": 98753095,
|
||||
"victim_corp_ticker": "KSTJK",
|
||||
"victim_ship_name": "Capsule",
|
||||
"victim_ship_type_id": 670,
|
||||
"zkb": {
|
||||
"awox": false,
|
||||
"destroyedValue": 10000,
|
||||
"droppedValue": 0,
|
||||
"fittedValue": 10000,
|
||||
"hash": "777148f8bf344bade68a6a0821bfe0a37491a7a6",
|
||||
"labels": ["cat:6","#:1","pvp","loc:highsec"],
|
||||
"locationID": 50014064,
|
||||
"npc": false,
|
||||
"points": 1,
|
||||
"solo": false,
|
||||
"totalValue": 10000
|
||||
}
|
||||
},
|
||||
{
|
||||
"attacker_count": 3,
|
||||
"final_blow_alliance_id": null,
|
||||
"final_blow_char_id": null,
|
||||
"final_blow_corp_id": null,
|
||||
"final_blow_ship_type_id": 3740,
|
||||
"kill_time": "2025-01-21T21:00:38Z",
|
||||
"killmail_id": 124181769,
|
||||
"npc": true,
|
||||
"solar_system_id": 30002768,
|
||||
"total_value": 2656048.48,
|
||||
"victim_alliance_id": 99013806,
|
||||
"victim_alliance_ticker": "TCE",
|
||||
"victim_char_id": 2116802745,
|
||||
"victim_char_name": "Brittni Bunny",
|
||||
"victim_corp_id": 98140648,
|
||||
"victim_corp_ticker": "GNK3D",
|
||||
"victim_ship_name": "Coercer",
|
||||
"victim_ship_type_id": 16236,
|
||||
"zkb": {
|
||||
"awox": false,
|
||||
"destroyedValue": 2509214.44,
|
||||
"droppedValue": 146834.04,
|
||||
"fittedValue": 2607449.82,
|
||||
"hash": "d3dd6b8833b2a9d36dd5a3eecf9838c4c8b01acd",
|
||||
"labels": ["cat:6","#:2+","npc","loc:highsec"],
|
||||
"locationID": 50014064,
|
||||
"npc": true,
|
||||
"points": 1,
|
||||
"solo": false,
|
||||
"totalValue": 2656048.48
|
||||
}
|
||||
}
|
||||
],
|
||||
"solar_system_id": 30002768
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
Using these APIs, you can programmatically retrieve system and character information from your map. Whether you’re building a custom analytics dashboard, a corp management tool, or just want to explore data outside the standard UI, these endpoints provide a straightforward way to fetch up-to-date map details.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
%{
|
||||
title: "Managing Upwell Structures & Timers with the Structures Widget",
|
||||
author: "Wanderer Team",
|
||||
cover_image_uri: "/images/news/structure-widget/cover.png",
|
||||
cover_image_uri: "/images/news/01-20-structure-widget/cover.png",
|
||||
tags: ~w(interface guide map structures),
|
||||
description: "Learn how to track structure information using the Structures Widget."
|
||||
}
|
||||
@@ -22,7 +22,7 @@ In this guide, we’ll explore how to enable the Structures Widget, manage struc
|
||||
|
||||
### 1. Enabling the Structure Widget
|
||||
|
||||

|
||||

|
||||
|
||||
1. **Open the Map:**
|
||||
2. **Locate the Widget Settings:** By default, the structure widget panel is not visible. Enable it by going to menu -> map settings -> widgets.
|
||||
@@ -34,7 +34,7 @@ In this guide, we’ll explore how to enable the Structures Widget, manage struc
|
||||
|
||||
### 2. Overview of the Structures Widget
|
||||
|
||||

|
||||

|
||||
|
||||
Once enabled, the **Structures Widget** appears in the map. It shows:
|
||||
|
||||
|
||||
78
priv/posts/2025/01-27-zkill-widget.md
Normal file
78
priv/posts/2025/01-27-zkill-widget.md
Normal file
@@ -0,0 +1,78 @@
|
||||
%{
|
||||
title: "Tracking Kills with the New zKill Widget",
|
||||
author: "Wanderer Team",
|
||||
cover_image_uri: "/images/news/01-27-zkill-widget/cover.png",
|
||||
tags: ~w(kills zkill interface guide map),
|
||||
description: "Stay informed about kills across New Eden using the zKill Widget."
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
### Introduction
|
||||
|
||||
Keeping tabs on kills in your local system or across multiple systems is crucial for both solo pilots and large corporations in EVE Online. Real-time intel on **who was killed**, **where it happened**, and **how valuable the loss was** can help you plan fleets, spot emerging threats, or detect new prey. Our brand-new **zKill Widget** (inspired by the popular zKillboard service) brings this intelligence directly into your map—no more juggling multiple tabs or missing out on real-time kill updates.
|
||||
|
||||
In this guide, we’ll walk through **enabling**, **using**, and **customizing** the zKill Widget so you can spend more time flying and less time managing your intel tools.
|
||||
|
||||
---
|
||||
|
||||
### 1. Enabling the zKill Widget
|
||||
|
||||

|
||||
|
||||
1. **Open the Map** in your browser
|
||||
2. **Access Widget Settings:** Look for the map’s settings widget menu. You’ll find a list of widgets (e.g., Structures, Route Finder, etc.).
|
||||
3. **Add the zKill Widget:** Check the box for **“Kills Widget”** from the widgets list. It should then appear in your map interface.
|
||||
4. **Update your settings** Use the widget menu to select compact mode, or to exclude systems you don't wish to track
|
||||
|
||||
> **Tip:** You can reposition the widget panel to fit your workflow—drag it around the interface to dock it where you prefer.
|
||||
|
||||
---
|
||||
|
||||
### 2. Exploring the Kills Widget Interface
|
||||
|
||||
Once enabled, the **zKill Widget** will display a list of recent killmails. This includes:
|
||||
|
||||
- **Victim and Attacker Info:** Shows character, corporation, alliance, and the ships involved.
|
||||
- **Kill Value (ISK):** Highlights the total value destroyed in each killmail.
|
||||
- **Timestamp:** See how long ago the kill occurred.
|
||||
- **System Name:** If you’re focusing on a single system, it displays kills only in that system by default.
|
||||
- **“Show All Systems” Toggle:** Switch between a single-system view and a all-systems overview.
|
||||
|
||||
#### Key Features
|
||||
|
||||
1. **Killmail Details:** Clicking the ship icon or kill entry will take you straight to the kill’s page on [zKillboard](https://zkillboard.com/), where you can dive deeper into the kill’s participants and fitting details.
|
||||
2. **Final Blow Tag:** Ever wonder who landed the final shot? It’s displayed prominently, including the attacker’s corporation/alliance ticker and ship type.
|
||||
3. **Collapsible/Movable Panel:** The entire widget can be collapsed to save screen space and reopened when you need intel at a glance.
|
||||
|
||||
---
|
||||
|
||||
### 3. “Show All” vs. Single-System Mode
|
||||
|
||||
One of the unique features of this widget is the **`Show all systems`** checkbox:
|
||||
|
||||
- **Single-System Mode:** By default, it displays only kills for the current system you’re viewing on the map
|
||||
- **Show All Systems Mode:** Check this box to expand your scope and keep track of kills across every system in your map.
|
||||
|
||||
---
|
||||
|
||||
|
||||
### 5. Practical Use Cases
|
||||
|
||||
1. **Hole Defense:** If your alliance is staging a defense in a wormhole, set the widget to show kills in that system only. Instantly see if enemies are picking off your allies.
|
||||
2. **Hunting Ratters & Miners:** Scouts can monitor potential targets’ death in real time, quickly relaying intel to corp mates.
|
||||
3. **Market Intel & Loot Value:** Keep tabs on high-value killmails (e.g., expensive T3 cruisers or officer-fitted battleships) for loot-scooping opportunities or to gauge where the most traffic is.
|
||||
|
||||
---
|
||||
|
||||
|
||||
### 7. Conclusion
|
||||
|
||||
The **zKill Widget** brings essential kill intel right to your map’s interface, offering real-time data on PvP and PvE engagements across New Eden. Whether you’re a solo pilot scanning for targets or part of a major alliance coordinating strategic defenses, this widget keeps you one step ahead. Monitor kills in your home system, track them across multiple systems, and drill down into kill details without ever leaving your map.
|
||||
|
||||
Start using the zKill Widget today and enjoy a more efficient, streamlined intel experience!
|
||||
|
||||
Fly safe,
|
||||
**The Wanderer Team**
|
||||
|
||||
---
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"21000325": true,
|
||||
"21000326": true,
|
||||
"21000327": true,
|
||||
"21000328": true,
|
||||
"21000329": true,
|
||||
"21000330": true
|
||||
}
|
||||
"21000325": true,
|
||||
"21000326": true,
|
||||
"21000327": true,
|
||||
"21000328": true,
|
||||
"21000329": true,
|
||||
"21000330": true,
|
||||
"21000333": true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user