Compare commits

...

11 Commits

Author SHA1 Message Date
CI
cd0b4b0fc9 chore: release version v1.44.0 2025-02-01 15:22:11 +00:00
Aleksei Chichenkov
e7b115e6e6 Merge pull request #124 from guarzo/guarzo/zkill
feat: add zkill widget
2025-02-01 18:21:40 +03:00
Gustav
dff8fc6396 refactor: additional design feedback improvements 2025-01-31 15:00:46 -07:00
Gustav
afdaeb3d34 fix: design feedback patch 2025-01-31 15:00:46 -07:00
Gustav
ac6053361e fix: removed unneeded event handler 2025-01-31 15:00:46 -07:00
Gustav
eb3e1ba3aa feat: add news post for zkill widget 2025-01-31 15:00:46 -07:00
Gustav
8468a9b5de feat: add zkill widget 2025-01-31 15:00:46 -07:00
CI
5eafe59dcb chore: release version v1.43.9
Some checks failed
Build / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build / merge (push) Has been cancelled
Build / 🏷 Create Release (push) Has been cancelled
2025-01-30 21:55:35 +00:00
Dmitry Popov
b38bcaa8cf fix(Core): Add discord link to 'Like' icon on main interface 2025-01-30 22:55:04 +01:00
CI
8a238a447d chore: release version v1.43.8
Some checks failed
Build / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build / merge (push) Has been cancelled
Build / 🏷 Create Release (push) Has been cancelled
2025-01-26 16:21:18 +00:00
Dmitry Popov
3731219216 fix(Core): Update shuttered constellations (required EVE DB data update on server). 2025-01-26 17:20:28 +01:00
59 changed files with 3311 additions and 466 deletions

View File

@@ -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"

View File

@@ -2,6 +2,41 @@
<!-- changelog -->
## [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)

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -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>
);
};

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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} />;
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1,2 @@
export * from './linkHelpers';
export * from './killRowUtils';

View File

@@ -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;
}

View File

@@ -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');
}

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -0,0 +1 @@
export * from './SystemKills';

View File

@@ -3,3 +3,4 @@ export * from './SystemInfo';
export * from './RoutesWidget';
export * from './SystemSignatures';
export * from './SystemStructures';
export * from './SystemKills';

View File

@@ -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}

View File

@@ -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;

View File

@@ -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">

View File

@@ -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';

View File

@@ -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;
}
}

View File

@@ -11,11 +11,13 @@ import {
WindowStoreInfo,
} from '@/hooks/Mapper/mapRootProvider/hooks/useStoreWidgets.ts';
import { CommandLinkSignatureToSystem } from '@/hooks/Mapper/types';
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 +33,7 @@ const INITIAL_DATA: MapRootData = {
routes: undefined,
kills: [],
connections: [],
detailedKills: {},
selectedSystems: [],
selectedConnections: [],
userPermissions: {},

View File

@@ -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,
};
};

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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>;

View File

@@ -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',

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -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,

View File

@@ -127,7 +127,6 @@ defmodule WandererApp.Api.Map do
update :update_api_key do
accept [:public_api_key]
end
end
attributes do

View File

@@ -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

View File

@@ -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())

View File

@@ -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

View File

@@ -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,

View File

@@ -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}",

View File

@@ -6,114 +6,205 @@ 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", %{})
updated_kills_system_ids =
old_kills_map
|> Map.keys()
|> Enum.filter(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
end)
removed_kills_system_ids =
old_kills_map
|> Map.keys()
|> Enum.filter(fn system_id ->
old_kills_count = Map.get(old_kills_map, system_id, 0)
new_kills_count = Map.get(new_kills_map, system_id, 0)
old_kills_count > 0 and new_kills_count == 0
end)
changed_system_ids = updated_kills_system_ids ++ removed_kills_system_ids
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

View 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

View File

@@ -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

View File

@@ -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

View 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 its 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

View 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

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -3,7 +3,6 @@ defmodule WandererAppWeb.MapStructuresEventHandler do
use Phoenix.Component
require Logger
alias WandererAppWeb.MapEventHandler
alias WandererApp.Api.MapSystem
alias WandererApp.Structure

View File

@@ -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)

View File

@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
@source_url "https://github.com/wanderer-industries/wanderer"
@version "1.43.7"
@version "1.44.0"
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"},

View File

@@ -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"},

View 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, well 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
![Enabling the zKill Widget](/images/news/01-27-zkill-widget/enable-zkill.png "Enable zKill Widget")
1. **Open the Map** in your browser
2. **Access Widget Settings:** Look for the maps settings widget menu. Youll 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 youre 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 kills page on [zKillboard](https://zkillboard.com/), where you can dive deeper into the kills participants and fitting details.
2. **Final Blow Tag:** Ever wonder who landed the final shot? Its displayed prominently, including the attackers 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 youre 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 maps interface, offering real-time data on PvP and PvE engagements across New Eden. Whether youre 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**
---

View File

@@ -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
}