Compare commits

..

29 Commits

Author SHA1 Message Date
CI
4af12c21b2 chore: release version v1.44.3 2025-02-02 21:18:47 +00:00
guarzo
497da1e5f7 fix: restored kills lightning bolt functionality (#143) 2025-02-03 01:18:21 +04:00
CI
5bd968acae chore: release version v1.44.2 2025-02-02 19:54:11 +00:00
guarzo
f74c20142c Add api for visible system kill information (#133)
* feat: api for zkill information
2025-02-02 23:53:40 +04:00
CI
d4c40d7542 chore: release version v1.44.1
Some checks are pending
Build / 🚀 Deploy to test env (fly.io) (push) Waiting to run
Build / 🛠 Build (1.17, 18.x, 27) (push) Waiting to run
Build / 🛠 Build Docker Images (linux/amd64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64) (push) Blocked by required conditions
Build / merge (push) Blocked by required conditions
Build / 🏷 Create Release (push) Blocked by required conditions
2025-02-01 20:34:37 +00:00
Aleksei Chichenkov
04f3fec0c0 fix(Map): Fixed problem with windows. (#140)
* fix(Map): Fixed problem with windows.

* fix(Core): Added min heigth for body

---------

Co-authored-by: achichenkov <aleksei.chichenkov@telleqt.ai>
Co-authored-by: Dmitry Popov <dmitriypopovsamara@gmail.com>
2025-02-02 00:34:08 +04:00
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
CI
73d5fd5f67 chore: release version v1.43.7 2025-01-26 16:12:32 +00:00
Dmitry Popov
e8e4aed6d5 Signature EOL status support (#136)
* feat(Map): Added an ability to mark signature as EOL

* chore: release version v1.39.1

* fix(Map): Add correct styles for switch

* fix(Map): Refactor signatures code. Add ability to set EOL for signature marked as EOL

* feat(Map): Added EOL status for unsplashed signatures. Show precise time for connection passages on hover.

---------

Co-authored-by: achichenkov <aleksei.chichenkov@telleqt.ai>
2025-01-26 20:12:07 +04:00
CI
63571a462f chore: release version v1.43.6
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-22 17:41:42 +00:00
Dmitry Popov
606add4142 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-01-22 18:40:47 +01:00
Dmitry Popov
dac480b059 fix(Widgets): Fix widgets not visible on map 2025-01-22 18:40:37 +01:00
CI
5f67cb1dd7 chore: release version v1.43.5 2025-01-22 17:07:37 +00:00
Dmitry Popov
5886fff753 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-01-22 18:06:22 +01:00
Dmitry Popov
da2e12bdd1 fix(Audit): Fix signature added/removed system name 2025-01-22 18:06:03 +01:00
CI
05c3d20e56 chore: release version v1.43.4
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-21 08:48:20 +00:00
guarzo
4633d26517 fix: improve structure widget styling (#127) 2025-01-21 12:47:40 +04:00
CI
30b0556d47 chore: release version v1.43.3 2025-01-21 08:46:40 +00:00
Dmitry Popov
e094378dc5 chore: release version v1.43.2 2025-01-21 09:46:09 +01:00
91 changed files with 4146 additions and 804 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,105 @@
<!-- changelog -->
## [v1.44.3](https://github.com/wanderer-industries/wanderer/compare/v1.44.2...v1.44.3) (2025-02-02)
### Bug Fixes:
* restored kills lightning bolt functionality (#143)
## [v1.44.2](https://github.com/wanderer-industries/wanderer/compare/v1.44.1...v1.44.2) (2025-02-02)
## [v1.44.1](https://github.com/wanderer-industries/wanderer/compare/v1.44.0...v1.44.1) (2025-02-01)
### Bug Fixes:
* Map: Fixed problem with windows. (#140)
* Map: Fixed problem with windows.
* Core: Added min heigth for body
## [v1.44.0](https://github.com/wanderer-industries/wanderer/compare/v1.43.9...v1.44.0) (2025-02-01)
### Features:
* add news post for zkill widget
* add zkill widget
### Bug Fixes:
* design feedback patch
* removed unneeded event handler
## [v1.43.9](https://github.com/wanderer-industries/wanderer/compare/v1.43.8...v1.43.9) (2025-01-30)
### Bug Fixes:
* Core: Add discord link to 'Like' icon on main interface
## [v1.43.8](https://github.com/wanderer-industries/wanderer/compare/v1.43.7...v1.43.8) (2025-01-26)
### Bug Fixes:
* Core: Update shuttered constellations (required EVE DB data update on server).
## [v1.43.7](https://github.com/wanderer-industries/wanderer/compare/v1.43.6...v1.43.7) (2025-01-26)
## [v1.43.6](https://github.com/wanderer-industries/wanderer/compare/v1.43.5...v1.43.6) (2025-01-22)
### Bug Fixes:
* Widgets: Fix widgets not visible on map
## [v1.43.5](https://github.com/wanderer-industries/wanderer/compare/v1.43.4...v1.43.5) (2025-01-22)
### Bug Fixes:
* Audit: Fix signature added/removed system name
## [v1.43.4](https://github.com/wanderer-industries/wanderer/compare/v1.43.3...v1.43.4) (2025-01-21)
### Bug Fixes:
* improve structure widget styling (#127)
## [v1.43.3](https://github.com/wanderer-industries/wanderer/compare/v1.43.2...v1.43.3) (2025-01-21)
## [v1.43.2](https://github.com/wanderer-industries/wanderer/compare/v1.43.1...v1.43.2) (2025-01-21)

View File

@@ -25,6 +25,10 @@ body {
width: 400px; /* As IE6 ignores !important it will set width as 400px; */
}
body > div:first-of-type {
min-height: 500px !important;
}
.lending-normal {
font-family: 'Shentox', 'Rogan', sans-serif !important;
font-weight: 500;

View File

@@ -112,3 +112,28 @@
.p-autocomplete .p-autocomplete-multiple-container .p-autocomplete-token {
margin-right: 0 !important;
}
/* Fixed sizes of Input switch */
.p-inputswitch {
width: 2.0rem;
height: 1.15rem;
.p-inputswitch-slider:before {
width: 0.8rem;
height: 0.8rem;
left: 0.14rem;
margin-top: -0.385rem;
}
&.p-highlight .p-inputswitch-slider:before {
transform: translateX(0.8rem);
}
&:not(.p-disabled):has(.p-inputswitch-input:hover) .p-inputswitch-slider {
background: rgb(255 255 255 / 21%);
}
&.p-highlight .p-inputswitch-slider {
background: #966d3d;
}
}

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

@@ -15,4 +15,8 @@
font-weight: bolder;
display: block;
}
& > .Eol {
display: block;
}
}

View File

@@ -8,8 +8,8 @@ import { WORMHOLE_CLASS_STYLES, WORMHOLES_ADDITIONAL_INFO } from '@/hooks/Mapper
import { useMemo } from 'react';
import clsx from 'clsx';
import { renderInfoColumn } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/renders';
import { k162Types } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureK162TypeSelect';
import { K162_TYPES_MAP } from '@/hooks/Mapper/constants.ts';
import { parseSignatureCustomInfo } from '@/hooks/Mapper/helpers/parseSignatureCustomInfo.ts';
interface UnsplashedSignatureProps {
signature: SystemSignature;
@@ -22,17 +22,22 @@ export const UnsplashedSignature = ({ signature }: UnsplashedSignatureProps) =>
const whData = useMemo(() => wormholesData[signature.type], [signature.type, wormholesData]);
const whClass = useMemo(() => (whData ? WORMHOLES_ADDITIONAL_INFO[whData.dest] : null), [whData]);
const k162TypeOption = useMemo(() => {
if (!signature.custom_info) {
return null;
}
const customInfo = JSON.parse(signature.custom_info);
if (!customInfo.k162Type) {
return null;
}
return k162Types.find(x => x.value === customInfo.k162Type);
const customInfo = useMemo(() => {
return parseSignatureCustomInfo(signature.custom_info);
}, [signature]);
const k162TypeOption = useMemo(() => {
if (!customInfo?.k162Type) {
return null;
}
return K162_TYPES_MAP[customInfo.k162Type];
}, [customInfo]);
const isEOL = useMemo(() => {
return customInfo?.isEOL;
}, [customInfo]);
const whClassStyle = useMemo(() => {
if (signature.type === 'K162' && k162TypeOption) {
const k162Data = wormholesData[k162TypeOption.whClassName];
@@ -45,19 +50,19 @@ export const UnsplashedSignature = ({ signature }: UnsplashedSignatureProps) =>
return (
<WdTooltipWrapper
className={clsx(classes.Signature)}
// @ts-ignore
content={
(
<div className="flex flex-col gap-1">
<InfoDrawer title={<b className="text-slate-50">{signature.eve_id}</b>}>
{renderInfoColumn(signature)}
</InfoDrawer>
</div>
) as React.ReactNode
<div className="flex flex-col gap-1">
<InfoDrawer title={<b className="text-slate-50">{signature.eve_id}</b>}>
{renderInfoColumn(signature)}
</InfoDrawer>
</div>
}
>
<div className={clsx(classes.Box, whClassStyle)}>
<svg width="13" height="4" viewBox="0 0 13 4" xmlns="http://www.w3.org/2000/svg">
<rect width="13" height="4" rx="2" className={whClassStyle} fill="currentColor" />
<svg width="13" height="8" viewBox="0 0 13 8" xmlns="http://www.w3.org/2000/svg">
<rect y="1" width="13" height="4" rx="2" className={whClassStyle} fill="currentColor" />
{isEOL && <rect x="4" width="5" height="6" rx="1" className={clsx(classes.Eol)} fill="#a153ac" />}
</svg>
</div>
</WdTooltipWrapper>

View File

@@ -21,5 +21,12 @@ export const MapInterface = () => {
.filter(x => windowsSettings.visible.some(j => x.id === j));
}, [windowsSettings]);
return <WindowManager windows={items} dragSelector=".react-grid-dragHandleExample" onChange={updateWidgetSettings} />;
return (
<WindowManager
windows={items}
viewPort={windowsSettings.viewPort}
dragSelector=".react-grid-dragHandleExample"
onChange={updateWidgetSettings}
/>
);
};

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

@@ -1,28 +1,29 @@
import { PrimeIcons } from 'primereact/api';
import { SignatureGroup, SystemSignature } from '@/hooks/Mapper/types';
import { SystemViewStandalone, WHClassView } from '@/hooks/Mapper/components/ui-kit';
import { SystemViewStandalone, TooltipPosition, WHClassView } from '@/hooks/Mapper/components/ui-kit';
import {
k162Types,
renderK162Type,
} from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureK162TypeSelect';
import { renderK162Type } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureK162TypeSelect';
import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper';
import clsx from 'clsx';
import { renderName } from './renderName.tsx';
import { K162_TYPES_MAP } from '@/hooks/Mapper/constants.ts';
import { parseSignatureCustomInfo } from '@/hooks/Mapper/helpers/parseSignatureCustomInfo.ts';
export const renderInfoColumn = (row: SystemSignature) => {
if (!row.group || row.group === SignatureGroup.Wormhole) {
let k162TypeOption = null;
if (row.custom_info) {
const customInfo = JSON.parse(row.custom_info);
if (customInfo.k162Type) {
k162TypeOption = k162Types.find(x => x.value === customInfo.k162Type);
}
}
const customInfo = parseSignatureCustomInfo(row.custom_info);
const k162TypeOption = customInfo.k162Type ? K162_TYPES_MAP[customInfo.k162Type] : null;
return (
<div className="flex justify-start items-center gap-[4px]">
{customInfo.isEOL && (
<WdTooltipWrapper offset={5} position={TooltipPosition.top} content="Signature marked as EOL">
<div className="pi pi-clock text-fuchsia-400 text-[11px] mr-[2px]"></div>
</WdTooltipWrapper>
)}
{row.type && (
<WHClassView
className="text-[11px]"
@@ -34,7 +35,7 @@ export const renderInfoColumn = (row: SystemSignature) => {
/>
)}
{!row.linked_system && row.type === 'K162' && !!k162TypeOption && <>{renderK162Type(k162TypeOption)}</>}
{!row.linked_system && row.type === 'K162' && k162TypeOption && renderK162Type(k162TypeOption)}
{row.linked_system && (
<>

View File

@@ -70,6 +70,11 @@ export const SystemStructures: React.FC = () => {
<WdImgButton
className={`${PrimeIcons.CLOCK} text-sky-400 hover:text-sky-200 transition duration-300`}
onClick={handlePasteTimer}
tooltip={{
position: TooltipPosition.left,
// @ts-ignore
content: 'Add Structures/Timer',
}}
/>
<WdImgButton
className={PrimeIcons.QUESTION_CIRCLE}

View File

@@ -1,18 +1,24 @@
.TableRowCompact {
height: 8px;
max-height: 8px;
font-size: 12px !important;
line-height: 8px;
}
.Table {
font-size: 12px;
border-collapse: collapse;
}
.TableRowCompact {
height: 8px;
max-height: 8px;
font-size: 12px !important;
line-height: 8px;
}
.Tooltip {
white-space: pre-line; // or pre-wrap
line-height: 1.2rem;
}
.Table {
font-size: 12px;
border-collapse: collapse;
table-layout: fixed;
width: 100%;
}
.Table .p-datatable-tbody > tr > td {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.Tooltip {
white-space: pre-line;
line-height: 1.2rem;
}

View File

@@ -63,6 +63,7 @@ export const SystemStructuresContent: React.FC<SystemStructuresContentProps> = (
size="small"
sortMode="single"
rowHover
style={{ tableLayout: 'fixed', width: '100%' }}
onRowClick={handleRowClick}
onRowDoubleClick={handleRowDoubleClick}
rowClassName={rowData => {
@@ -74,11 +75,56 @@ export const SystemStructuresContent: React.FC<SystemStructuresContentProps> = (
);
}}
>
<Column header="Type" body={renderTypeCell} style={{ width: '160px' }} />
<Column field="name" header="Name" style={{ width: '120px' }} />
<Column header="Owner" body={renderOwnerCell} style={{ width: '120px' }} />
<Column field="status" header="Status" style={{ width: '100px' }} />
<Column header="Timer" body={renderTimerCell} style={{ width: '110px' }} />
<Column
header="Type"
body={renderTypeCell}
style={{
width: '160px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
/>
<Column
field="name"
header="Name"
style={{
width: '120px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
/>
<Column
header="Owner"
body={renderOwnerCell}
style={{
width: '120px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
/>
<Column
field="status"
header="Status"
style={{
width: '100px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
/>
<Column
header="Timer"
body={renderTimerCell}
style={{
width: '110px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
/>
<Column
body={(rowData: StructureItem) => (
<i
@@ -90,7 +136,13 @@ export const SystemStructuresContent: React.FC<SystemStructuresContentProps> = (
}}
/>
)}
style={{ width: '40px', textAlign: 'center' }}
style={{
width: '40px',
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
/>
</DataTable>
</div>

View File

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

View File

@@ -2,7 +2,9 @@ import clsx from 'clsx';
import classes from './PassageCard.module.scss';
import { Passage } from '@/hooks/Mapper/types';
import { TimeAgo } from '@/hooks/Mapper/components/ui-kit';
import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper';
import { kgToTons } from '@/hooks/Mapper/utils/kgToTons.ts';
import { useMemo } from 'react';
type PassageCardType = {
// compact?: boolean;
@@ -26,6 +28,11 @@ export const getShipName = (name: string) => {
export const PassageCard = ({ inserted_at, character: char, ship }: PassageCardType) => {
const isOwn = false;
const insertedAt = useMemo(() => {
const date = new Date(inserted_at);
return date.toLocaleString();
}, [inserted_at]);
return (
<div className={clsx(classes.CharacterCard, 'w-full text-xs', 'flex flex-col box-border')}>
<div className="flex flex-col justify-between px-2 py-1 gap-1">
@@ -76,7 +83,9 @@ export const PassageCard = ({ inserted_at, character: char, ship }: PassageCardT
{/*time and class*/}
<div className="flex justify-between">
<span className="text-stone-400">
<TimeAgo timestamp={inserted_at} />
<WdTooltipWrapper content={insertedAt}>
<TimeAgo timestamp={inserted_at} />
</WdTooltipWrapper>
</span>
<div className="text-stone-400">{kgToTons(parseInt(ship.ship_type_info.mass))}</div>

View File

@@ -1,4 +1,5 @@
import styles from './MapSettings.module.scss';
import { WdCheckbox } from '@/hooks/Mapper/components/ui-kit';
interface PrettySwitchboxProps {

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

@@ -53,6 +53,7 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map
...out,
custom_info: JSON.stringify({
k162Type: values.k162Type,
isEOL: values.isEOL,
}),
};
@@ -127,14 +128,17 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map
const { linked_system, custom_info, ...rest } = signatureData;
let k162Type = null;
let isEOL = false;
if (custom_info) {
const customInfo = JSON.parse(custom_info);
k162Type = customInfo.k162Type;
isEOL = customInfo.isEOL;
}
signatureForm.reset({
linked_system: linked_system?.solar_system_id.toString() ?? undefined,
k162Type: k162Type,
isEOL: isEOL,
...rest,
});
}, [signatureForm, signatureData]);

View File

@@ -0,0 +1,24 @@
import { InputSwitch } from 'primereact/inputswitch';
import { Controller, useFormContext } from 'react-hook-form';
import { SystemSignature } from '@/hooks/Mapper/types';
export interface SignatureEOLCheckboxProps {
name: string;
defaultValue?: boolean;
}
export const SignatureEOLCheckbox = ({ name, defaultValue = false }: SignatureEOLCheckboxProps) => {
const { control } = useFormContext<SystemSignature>();
return (
<Controller
// @ts-ignore
name={name}
control={control}
defaultValue={defaultValue}
render={({ field }) => {
return <InputSwitch className="my-1" checked={!!field.value} onChange={e => field.onChange(e.value)} />;
}}
/>
);
};

View File

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

View File

@@ -3,6 +3,7 @@ import { SystemSignature } from '@/hooks/Mapper/types';
import { SignatureWormholeTypeSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureWormholeTypeSelect';
import { SignatureK162TypeSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureK162TypeSelect';
import { SignatureLeadsToSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureLeadsToSelect';
import { SignatureEOLCheckbox } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureEOLCheckbox';
export const SignatureGroupContentWormholes = () => {
const { watch } = useFormContext<SystemSignature>();
@@ -26,6 +27,11 @@ export const SignatureGroupContentWormholes = () => {
<span>Leads To:</span>
<SignatureLeadsToSelect name="linked_system" />
</label>
<label className="grid grid-cols-[100px_250px_1fr] gap-2 items-center text-[14px]">
<span>EOL:</span>
<SignatureEOLCheckbox name="isEOL" />
</label>
</>
);
};

View File

@@ -3,100 +3,8 @@ import clsx from 'clsx';
import { Controller, useFormContext } from 'react-hook-form';
import { useMemo } from 'react';
import { SystemSignature } from '@/hooks/Mapper/types';
import { WHClassView } from '@/hooks/Mapper/components/ui-kit';
export const k162Types = [
{
label: 'Hi-Sec',
value: 'hs',
whClassName: 'A641',
},
{
label: 'Low-Sec',
value: 'ls',
whClassName: 'J377',
},
{
label: 'Null-Sec',
value: 'ns',
whClassName: 'C248',
},
{
label: 'C1',
value: 'c1',
whClassName: 'E004',
},
{
label: 'C2',
value: 'c2',
whClassName: 'D382',
},
{
label: 'C3',
value: 'c3',
whClassName: 'L477',
},
{
label: 'C4',
value: 'c4',
whClassName: 'M001',
},
{
label: 'C5',
value: 'c5',
whClassName: 'L614',
},
{
label: 'C6',
value: 'c6',
whClassName: 'G008',
},
{
label: 'C13',
value: 'c13',
whClassName: 'A009',
},
{
label: 'Thera',
value: 'thera',
whClassName: 'F353',
},
{
label: 'Pochven',
value: 'pochven',
whClassName: 'F216',
},
];
const renderNoValue = () => <div className="flex gap-2 items-center">-Unknown-</div>;
// @ts-ignore
export const renderK162Type = (option: {
label?: string;
value: string;
security?: string;
system_class?: number;
whClassName?: string;
}) => {
if (!option) {
return renderNoValue();
}
const { value, whClassName = '' } = option;
if (value == null) {
return renderNoValue();
}
return (
<WHClassView
classNameWh="!text-[11px] !font-bold"
hideWhClassName
hideTooltip
whClassName={whClassName}
noOffset
useShortTitle
/>
);
};
import { K162_TYPES } from '@/hooks/Mapper/constants.ts';
import { renderK162Type } from '.';
export interface SignatureK162TypeSelectProps {
name: string;
@@ -107,7 +15,7 @@ export const SignatureK162TypeSelect = ({ name, defaultValue = '' }: SignatureK1
const { control } = useFormContext<SystemSignature>();
const options = useMemo(() => {
return [{ value: null }, ...k162Types];
return [{ value: null }, ...K162_TYPES];
}, []);
return (

View File

@@ -1 +1,2 @@
export * from './SignatureK162TypeSelect.tsx';
export * from './renderK162Type.tsx';

View File

@@ -0,0 +1,26 @@
import { WHClassView } from '@/hooks/Mapper/components/ui-kit';
import { K162Type } from '@/hooks/Mapper/constants.ts';
const renderNoValue = () => <div className="flex gap-2 items-center">-Unknown-</div>;
export const renderK162Type = (option: K162Type) => {
if (!option) {
return renderNoValue();
}
const { value, whClassName = '' } = option;
if (value == null) {
return renderNoValue();
}
return (
<WHClassView
classNameWh="!text-[11px] !font-bold"
hideWhClassName
hideTooltip
whClassName={whClassName}
noOffset
useShortTitle
/>
);
};

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

@@ -76,14 +76,22 @@ export const WindowWrapper = ({ onResize, onDrag, ...window }: WindowWrapperProp
</div>
);
};
export type ViewPortProps = { w: number; h: number };
export type WindowsManagerOnChange = (props: { windows: WindowProps[]; viewPort: ViewPortProps }) => void;
type WindowManagerProps = {
windows: WindowProps[];
viewPort?: ViewPortProps;
dragSelector?: string;
onChange?(windows: WindowProps[]): void;
onChange?: WindowsManagerOnChange;
};
export const WindowManager: React.FC<WindowManagerProps> = ({ windows: initialWindows, dragSelector, onChange }) => {
export const WindowManager: React.FC<WindowManagerProps> = ({
windows: initialWindows,
viewPort,
dragSelector,
onChange,
}) => {
const [windows, setWindows] = useState(
initialWindows.map((window, index) => ({
...window,
@@ -91,6 +99,16 @@ export const WindowManager: React.FC<WindowManagerProps> = ({ windows: initialWi
})),
);
const refPrevSize = useRef({ w: 0, h: 0 });
useEffect(() => {
if (!viewPort) {
return;
}
refPrevSize.current = viewPort;
}, [viewPort]);
useEffect(() => {
setWindows(initialWindows.slice(0));
}, [initialWindows]);
@@ -102,14 +120,15 @@ export const WindowManager: React.FC<WindowManagerProps> = ({ windows: initialWi
const startMousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const startWindowStateRef = useRef<{ x: number; y: number; width: number; height: number }>(DefaultWindowState);
const ref = useRef({ windows, onChange });
ref.current = { windows, onChange };
const refPrevSize = useRef({ w: 0, h: 0 });
const ref = useRef({ windows, viewPort, onChange });
ref.current = { windows, viewPort, onChange };
const onDebouncedChange = useMemo(() => {
return debounce(() => {
ref.current.onChange?.(ref.current.windows);
ref.current.onChange?.({
windows: ref.current.windows,
viewPort: refPrevSize.current,
});
}, 20);
}, []);
@@ -336,7 +355,7 @@ export const WindowManager: React.FC<WindowManagerProps> = ({ windows: initialWi
// Handle resize of the container and reposition windows
useEffect(() => {
if (containerRef.current) {
if (ref.current.viewPort == null && containerRef.current) {
refPrevSize.current = { w: containerRef.current.clientWidth, h: containerRef.current.clientHeight };
}
@@ -384,6 +403,14 @@ export const WindowManager: React.FC<WindowManagerProps> = ({ windows: initialWi
next.position.y = container.clientHeight - next.size.height - SNAP_GAP;
}
if (next.position.y < SNAP_GAP) {
next.position.y = 0;
}
if (next.position.x < SNAP_GAP) {
next.position.x = SNAP_GAP;
}
return next;
});
});

View File

@@ -65,3 +65,77 @@ export const REGIONS_MAP: Record<number, Spaces> = {
[Regions.TashMurkon]: Spaces.Amarr,
[Regions.VergeVendor]: Spaces.Gallente,
};
export type K162Type = {
label: string;
value: string;
whClassName: string;
};
export const K162_TYPES: K162Type[] = [
{
label: 'Hi-Sec',
value: 'hs',
whClassName: 'A641',
},
{
label: 'Low-Sec',
value: 'ls',
whClassName: 'J377',
},
{
label: 'Null-Sec',
value: 'ns',
whClassName: 'C248',
},
{
label: 'C1',
value: 'c1',
whClassName: 'E004',
},
{
label: 'C2',
value: 'c2',
whClassName: 'D382',
},
{
label: 'C3',
value: 'c3',
whClassName: 'L477',
},
{
label: 'C4',
value: 'c4',
whClassName: 'M001',
},
{
label: 'C5',
value: 'c5',
whClassName: 'L614',
},
{
label: 'C6',
value: 'c6',
whClassName: 'G008',
},
{
label: 'C13',
value: 'c13',
whClassName: 'A009',
},
{
label: 'Thera',
value: 'thera',
whClassName: 'F353',
},
{
label: 'Pochven',
value: 'pochven',
whClassName: 'F216',
},
];
export const K162_TYPES_MAP: { [key: string]: K162Type } = K162_TYPES.reduce(
(acc, x) => ({ ...acc, [x.value]: x }),
{},
);

View File

@@ -0,0 +1,9 @@
import { SignatureCustomInfo } from '@/hooks/Mapper/types';
export const parseSignatureCustomInfo = (str: string | undefined): SignatureCustomInfo => {
if (str == null || str === '') {
return {};
}
return JSON.parse(str);
};

View File

@@ -1,21 +1,27 @@
import { ContextStoreDataUpdate, useContextStore } from '@/hooks/Mapper/utils';
import { createContext, Dispatch, ForwardedRef, forwardRef, SetStateAction, useContext, useEffect } from 'react';
import { MapUnionTypes, OutCommandHandler, SolarSystemConnection } from '@/hooks/Mapper/types';
import {
CommandLinkSignatureToSystem,
MapUnionTypes,
OutCommandHandler,
SolarSystemConnection,
} from '@/hooks/Mapper/types';
import { useMapRootHandlers } from '@/hooks/Mapper/mapRootProvider/hooks';
import { WithChildren } from '@/hooks/Mapper/types/common.ts';
import useLocalStorageState from 'use-local-storage-state';
import {
ToggleWidgetVisibility,
UpdateWidgetSettingsFunc,
useStoreWidgets,
WindowStoreInfo,
} from '@/hooks/Mapper/mapRootProvider/hooks/useStoreWidgets.ts';
import { CommandLinkSignatureToSystem } from '@/hooks/Mapper/types';
import { WindowsManagerOnChange } from '@/hooks/Mapper/components/ui-kit/WindowManager';
import { DetailedKill } from '../types/kills';
export type MapRootData = MapUnionTypes & {
selectedSystems: string[];
selectedConnections: Pick<SolarSystemConnection, 'source' | 'target'>[];
linkSignatureToSystem: CommandLinkSignatureToSystem | null;
detailedKills: Record<string, DetailedKill[]>;
};
const INITIAL_DATA: MapRootData = {
@@ -31,7 +37,7 @@ const INITIAL_DATA: MapRootData = {
routes: undefined,
kills: [],
connections: [],
detailedKills: {},
selectedSystems: [],
selectedConnections: [],
userPermissions: {},
@@ -80,7 +86,7 @@ export interface MapRootContextProps {
setInterfaceSettings: Dispatch<SetStateAction<InterfaceStoredSettings>>;
windowsSettings: WindowStoreInfo;
toggleWidgetVisibility: ToggleWidgetVisibility;
updateWidgetSettings: UpdateWidgetSettingsFunc;
updateWidgetSettings: WindowsManagerOnChange;
resetWidgets: () => void;
}

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

@@ -8,13 +8,14 @@ import {
} from '@/hooks/Mapper/components/mapInterface/constants.tsx';
import { WindowProps } from '@/hooks/Mapper/components/ui-kit/WindowManager/types.ts';
import { useCallback, useEffect, useRef } from 'react';
import { SNAP_GAP } from '@/hooks/Mapper/components/ui-kit/WindowManager';
import { SNAP_GAP, WindowsManagerOnChange } from '@/hooks/Mapper/components/ui-kit/WindowManager';
export type StoredWindowProps = Omit<WindowProps, 'content'>;
export type WindowStoreInfo = {
version: number;
windows: StoredWindowProps[];
visible: WidgetsIds[];
viewPort?: { w: number; h: number } | undefined;
};
export type UpdateWidgetSettingsFunc = (widgets: WindowProps[]) => void;
export type ToggleWidgetVisibility = (widgetId: WidgetsIds) => void;
@@ -33,7 +34,7 @@ export const useStoreWidgets = () => {
const ref = useRef({ windowsSettings, setWindowsSettings });
ref.current = { windowsSettings, setWindowsSettings };
const updateWidgetSettings: UpdateWidgetSettingsFunc = useCallback(newWindows => {
const updateWidgetSettings: WindowsManagerOnChange = useCallback(({ windows, viewPort }) => {
const { setWindowsSettings } = ref.current;
setWindowsSettings(({ version, visible /*, windows*/ }: WindowStoreInfo) => {
@@ -41,13 +42,14 @@ export const useStoreWidgets = () => {
version,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
windows: DEFAULT_WIDGETS.map(({ content, ...x }) => {
const windowProp = newWindows.find(j => j.id === x.id);
const windowProp = windows.find(j => j.id === x.id);
if (windowProp) {
return windowProp;
}
return x;
}),
viewPort,
visible,
};
});
@@ -92,7 +94,7 @@ export const useStoreWidgets = () => {
return;
}
const { version, windows, visible } = JSON.parse(raw) as WindowStoreInfo;
const { version, windows, visible, viewPort } = JSON.parse(raw) as WindowStoreInfo;
if (!version || CURRENT_WINDOWS_VERSION > version) {
setWindowsSettings(getDefaultWidgetProps());
}
@@ -104,6 +106,7 @@ export const useStoreWidgets = () => {
version: CURRENT_WINDOWS_VERSION,
windows: out as WindowProps[],
visible,
viewPort,
});
}, []);

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

@@ -26,15 +26,20 @@ export type GroupType = {
h: number;
};
export type SignatureCustomInfo = {
k162Type?: string;
isEOL?: boolean;
};
export type SystemSignature = {
eve_id: string;
kind: SignatureKind;
name: string;
// SignatureCustomInfo
custom_info?: string;
description?: string;
group: SignatureGroup;
type: string;
k162Type?: string;
linked_system?: SolarSystemStaticInfoRaw;
inserted_at?: string;
updated_at?: string;

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

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

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,196 @@ defmodule WandererApp.Map.ZkbDataFetcher do
require Logger
alias WandererApp.Zkb.KillsProvider.KillsCache
@interval :timer.seconds(15)
@store_map_kills_timeout :timer.hours(1)
@logger Application.compile_env(:wanderer_app, :logger)
@pubsub_client Application.compile_env(:wanderer_app, :pubsub_client)
# This means 120 “ticks” of 15s each → ~30 minutes
@preload_cycle_ticks 120
def start_link(_) do
GenServer.start(__MODULE__, [], name: __MODULE__)
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
@impl true
def init([]) do
{:ok, timer} = :timer.send_interval(@interval, :fetch_data)
{:ok, %{timer: timer}}
def init(_arg) do
{:ok, _timer_ref} = :timer.send_interval(@interval, :fetch_data)
{:ok, %{iteration: 0}}
end
@impl true
def handle_info(:fetch_data, state) do
def handle_info(:fetch_data, %{iteration: iteration} = state) do
WandererApp.Map.RegistryHelper.list_all_maps()
|> Task.async_stream(
fn %{id: map_id, pid: _server_pid} ->
try do
map_id
|> WandererApp.Map.Server.map_pid()
|> case do
pid when is_pid(pid) ->
_update_map_kills(map_id)
if WandererApp.Map.Server.map_pid(map_id) do
update_map_kills(map_id)
nil ->
:ok
unless WandererApp.Env.zkill_preload_disabled?() do
update_detailed_map_kills(map_id)
end
end
rescue
e ->
@logger.error(Exception.message(e))
:ok
end
end,
max_concurrency: 10,
on_timeout: :kill_task
)
|> Enum.map(fn _ -> :ok end)
|> Enum.each(fn _ -> :ok end)
{:noreply, state}
end
new_iteration = iteration + 1
@impl true
def handle_info({ref, result}, state) do
Process.demonitor(ref, [:flush])
cond do
WandererApp.Env.zkill_preload_disabled?() ->
# If preload is disabled, just update iteration
{:noreply, %{state | iteration: new_iteration}}
{:noreply, state}
end
new_iteration >= @preload_cycle_ticks ->
Logger.info("[ZkbDataFetcher] Triggering a fresh kill preload pass ...")
WandererApp.Zkb.KillsPreloader.run_preload_now()
{:noreply, %{state | iteration: 0}}
defp _update_map_kills(map_id) do
case WandererApp.Cache.lookup!("map_#{map_id}:started", false) do
true ->
map_id
|> WandererApp.Map.get_map!()
|> Map.get(:systems, Map.new())
|> Enum.reduce(Map.new(), fn {solar_system_id, _system}, acc ->
kills_count = WandererApp.Cache.get("zkb_kills_#{solar_system_id}")
acc |> Map.put(solar_system_id, kills_count || 0)
end)
|> _maybe_broadcast_map_kills(map_id)
_ ->
:ok
{:noreply, %{state | iteration: new_iteration}}
end
end
defp _maybe_broadcast_map_kills(new_kills_map, map_id) do
{:ok, old_kills_map} = WandererApp.Cache.lookup("map_#{map_id}:zkb_kills", Map.new())
# Catch any async task results we aren't explicitly pattern-matching
@impl true
def handle_info({ref, _result}, state) do
Process.demonitor(ref, [:flush])
{:noreply, state}
end
updated_kills_system_ids =
new_kills_map
|> Map.filter(fn {solar_system_id, new_kills_count} ->
old_kills_count = old_kills_map |> Map.get(solar_system_id, 0)
new_kills_count != old_kills_count and
new_kills_count > 0
defp update_map_kills(map_id) do
with_started_map(map_id, "basic kills update", fn ->
map_id
|> WandererApp.Map.get_map!()
|> Map.get(:systems, %{})
|> Enum.into(%{}, fn {solar_system_id, _system} ->
kills_count = WandererApp.Cache.get("zkb_kills_#{solar_system_id}") || 0
{solar_system_id, kills_count}
end)
|> Map.keys()
|> maybe_broadcast_map_kills(map_id)
end)
end
removed_kills_system_ids =
old_kills_map
|> Map.filter(fn {solar_system_id, old_kills_count} ->
new_kills_count = new_kills_map |> Map.get(solar_system_id, 0)
defp update_detailed_map_kills(map_id) do
with_started_map(map_id, "detailed kills update", fn ->
systems =
map_id
|> WandererApp.Map.get_map!()
|> Map.get(:systems, %{})
old_kills_count > 0 and new_kills_count == 0
end)
|> Map.keys()
# Old cache data
old_ids_map = WandererApp.Cache.get("map_#{map_id}:zkb_ids") || %{}
old_details_map = WandererApp.Cache.get("map_#{map_id}:zkb_detailed_kills") || %{}
(updated_kills_system_ids ++ removed_kills_system_ids)
|> case do
[] ->
new_ids_map =
Enum.into(systems, %{}, fn {solar_system_id, _} ->
ids = KillsCache.get_system_killmail_ids(solar_system_id) |> MapSet.new()
{solar_system_id, ids}
end)
changed_systems =
new_ids_map
|> Enum.filter(fn {system_id, new_ids_set} ->
old_set = MapSet.new(Map.get(old_ids_map, system_id, []))
not MapSet.equal?(new_ids_set, old_set)
end)
|> Enum.map(&elem(&1, 0))
if changed_systems == [] do
Logger.debug("[ZkbDataFetcher] No changes in detailed kills for map_id=#{map_id}")
:ok
else
# Build new details for each changed system
updated_details_map =
Enum.reduce(changed_systems, old_details_map, fn system_id, acc ->
kill_ids =
new_ids_map
|> Map.fetch!(system_id)
|> MapSet.to_list()
system_ids ->
:ok =
WandererApp.Cache.put("map_#{map_id}:zkb_kills", new_kills_map,
ttl: @store_map_kills_timeout
)
kill_details =
kill_ids
|> Enum.map(&KillsCache.get_killmail/1)
|> Enum.reject(&is_nil/1)
Map.put(acc, system_id, kill_details)
end)
updated_ids_map =
Enum.reduce(changed_systems, old_ids_map, fn system_id, acc ->
new_ids_list = new_ids_map[system_id] |> MapSet.to_list()
Map.put(acc, system_id, new_ids_list)
end)
WandererApp.Cache.put("map_#{map_id}:zkb_ids", updated_ids_map,
ttl: :timer.hours(KillsCache.killmail_ttl)
)
WandererApp.Cache.put("map_#{map_id}:zkb_detailed_kills", updated_details_map,
ttl: :timer.hours(KillsCache.killmail_ttl)
)
changed_data = Map.take(updated_details_map, changed_systems)
@pubsub_client.broadcast!(WandererApp.PubSub, map_id, %{
event: :kills_updated,
payload: new_kills_map |> Map.take(system_ids)
event: :detailed_kills_updated,
payload: changed_data
})
:ok
end
end)
end
defp maybe_broadcast_map_kills(new_kills_map, map_id) do
{:ok, old_kills_map} = WandererApp.Cache.lookup("map_#{map_id}:zkb_kills", %{})
# Use the union of keys from both the new and old maps
all_system_ids = Map.keys(Map.merge(new_kills_map, old_kills_map))
changed_system_ids =
Enum.filter(all_system_ids, fn system_id ->
new_kills_count = Map.get(new_kills_map, system_id, 0)
old_kills_count = Map.get(old_kills_map, system_id, 0)
new_kills_count != old_kills_count and
(new_kills_count > 0 or (old_kills_count > 0 and new_kills_count == 0))
end)
if changed_system_ids == [] do
:ok
else
:ok =
WandererApp.Cache.put("map_#{map_id}:zkb_kills", new_kills_map,
ttl: @store_map_kills_timeout
)
payload = Map.take(new_kills_map, changed_system_ids)
@pubsub_client.broadcast!(WandererApp.PubSub, map_id, %{
event: :kills_updated,
payload: payload
})
:ok
end
end
defp with_started_map(map_id, label \\ "operation", fun) when is_function(fun, 0) do
if WandererApp.Cache.lookup!("map_#{map_id}:started", false) do
fun.()
else
Logger.debug("[ZkbDataFetcher] Map #{map_id} not started => skipping #{label}")
:ok
end
end
end

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,63 @@
defmodule WandererAppWeb.CommonAPIController do
use WandererAppWeb, :controller
alias WandererApp.CachedInfo
alias WandererAppWeb.UtilAPIController, as: Util
@doc """
GET /api/common/system_static?id=<solar_system_id>
Requires 'id' (the solar_system_id).
Example:
GET /api/common/system_static?id=31002229
"""
def show_system_static(conn, params) do
with {:ok, solar_system_str} <- Util.require_param(params, "id"),
{:ok, solar_system_id} <- Util.parse_int(solar_system_str) do
case CachedInfo.get_system_static_info(solar_system_id) do
{:ok, system} ->
data = static_system_to_json(system)
json(conn, %{data: data})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "System not found"})
end
else
{:error, msg} ->
conn
|> put_status(:bad_request)
|> json(%{error: msg})
end
end
# ----------------------------------------------
# Private helpers
# ----------------------------------------------
defp static_system_to_json(system) do
system
|> Map.take([
:solar_system_id,
:region_id,
:constellation_id,
:solar_system_name,
:solar_system_name_lc,
:constellation_name,
:region_name,
:system_class,
:security,
:type_description,
:class_title,
:is_shattered,
:effect_name,
:effect_power,
:statics,
:wandering,
:triglavian_invasion_status,
:sun_type_id
])
end
end

View File

@@ -1,87 +1,48 @@
defmodule WandererAppWeb.APIController do
defmodule WandererAppWeb.MapAPIController do
use WandererAppWeb, :controller
import Ash.Query, only: [filter: 2]
require Logger
alias WandererApp.Api
alias WandererApp.Api.Character
alias WandererApp.MapSystemRepo
alias WandererApp.MapCharacterSettingsRepo
alias WandererApp.Api.Character
alias WandererApp.CachedInfo
alias WandererApp.Zkb.KillsProvider.KillsCache
# -----------------------------------------------------------------
# Common
# -----------------------------------------------------------------
@doc """
GET /api/system-static-info
Requires 'id' (the solar_system_id)
Example:
GET /api/common/system_static?id=31002229
GET /api/common/system_static?id=31002229
"""
def show_system_static(conn, params) do
with {:ok, solar_system_str} <- require_param(params, "id"),
{:ok, solar_system_id} <- parse_int(solar_system_str) do
case CachedInfo.get_system_static_info(solar_system_id) do
{:ok, system} ->
data = static_system_to_json(system)
json(conn, %{data: data})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "System not found"})
end
else
{:error, msg} ->
conn
|> put_status(:bad_request)
|> json(%{error: msg})
end
end
alias WandererAppWeb.UtilAPIController, as: Util
# -----------------------------------------------------------------
# MAP endpoints
# -----------------------------------------------------------------
@doc """
GET /api/map/systems
Requires either `?map_id=<UUID>` **OR** `?slug=<map-slug>` in the query params.
If `?all=true` is provided, **all** systems are returned.
Otherwise, only "visible" systems are returned.
Only "visible" systems are returned.
Examples:
GET /api/map/systems?map_id=466e922b-e758-485e-9b86-afae06b88363
GET /api/map/systems?slug=my-unique-wormhole-map
GET /api/map/systems?map_id=<UUID>&all=true
"""
def list_systems(conn, params) do
with {:ok, map_id} <- fetch_map_id(params) do
repo_fun =
if params["all"] == "true" do
&MapSystemRepo.get_all_by_map/1
else
&MapSystemRepo.get_visible_by_map/1
end
case repo_fun.(map_id) do
{:ok, systems} ->
data = Enum.map(systems, &map_system_to_json/1)
json(conn, %{data: data})
{:error, reason} ->
conn
|> put_status(:not_found)
|> json(%{error: "Could not fetch systems for map_id=#{map_id}: #{inspect(reason)}"})
end
with {:ok, map_id} <- Util.fetch_map_id(params),
{:ok, systems} <- MapSystemRepo.get_visible_by_map(map_id) do
data = Enum.map(systems, &map_system_to_json/1)
json(conn, %{data: data})
else
{:error, msg} ->
{:error, msg} when is_binary(msg) ->
conn
|> put_status(:bad_request)
|> json(%{error: msg})
{:error, reason} ->
conn
|> put_status(:not_found)
|> json(%{error: "Could not fetch systems: #{inspect(reason)}"})
end
end
@@ -96,29 +57,30 @@ defmodule WandererAppWeb.APIController do
GET /api/map/system?id=31002229&slug=my-unique-wormhole-map
"""
def show_system(conn, params) do
with {:ok, solar_system_str} <- require_param(params, "id"),
{:ok, solar_system_id} <- parse_int(solar_system_str),
{:ok, map_id} <- fetch_map_id(params) do
case MapSystemRepo.get_by_map_and_solar_system_id(map_id, solar_system_id) do
{:ok, system} ->
data = map_system_to_json(system)
json(conn, %{data: data})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "System not found in map=#{map_id}"})
end
with {:ok, solar_system_str} <- Util.require_param(params, "id"),
{:ok, solar_system_id} <- Util.parse_int(solar_system_str),
{:ok, map_id} <- Util.fetch_map_id(params),
{:ok, system} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, solar_system_id) do
data = map_system_to_json(system)
json(conn, %{data: data})
else
{:error, msg} ->
{:error, msg} when is_binary(msg) ->
conn
|> put_status(:bad_request)
|> json(%{error: msg})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "System not found"})
{:error, reason} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "Could not load system: #{inspect(reason)}"})
end
end
@doc """
GET /api/map/tracked_characters_with_info
@@ -129,11 +91,9 @@ defmodule WandererAppWeb.APIController do
Returns a list of tracked records, plus their fully-loaded `character` data.
"""
def tracked_characters_with_info(conn, params) do
with {:ok, map_id} <- fetch_map_id(params),
with {:ok, map_id} <- Util.fetch_map_id(params),
{:ok, settings_list} <- get_tracked_by_map_ids(map_id),
{:ok, char_list} <-
read_characters_by_ids_wrapper(Enum.map(settings_list, & &1.character_id)) do
{:ok, char_list} <- read_characters_by_ids_wrapper(Enum.map(settings_list, & &1.character_id)) do
chars_by_id = Map.new(char_list, &{&1.id, &1})
data =
@@ -175,8 +135,24 @@ defmodule WandererAppWeb.APIController do
end
end
@doc """
GET /api/map/structure_timers
Returns structure timers for visible systems on the map
or for a specific system if `system_id` is specified.
**Example usage**:
- All visible systems:
```
GET /api/map/structure_timers?map_id=<uuid>
```
- For a single system:
```
GET /api/map/structure_timers?map_id=<uuid>&system_id=31002229
```
"""
def show_structure_timers(conn, params) do
with {:ok, map_id} <- fetch_map_id(params) do
with {:ok, map_id} <- Util.fetch_map_id(params) do
system_id_str = params["system_id"]
case system_id_str do
@@ -184,7 +160,7 @@ defmodule WandererAppWeb.APIController do
handle_all_structure_timers(conn, map_id)
_ ->
case parse_int(system_id_str) do
case Util.parse_int(system_id_str) do
{:ok, system_id} ->
handle_single_structure_timers(conn, map_id, system_id)
@@ -202,6 +178,102 @@ defmodule WandererAppWeb.APIController do
end
end
@doc """
GET /api/map/systems_kills
Returns kills data for all *visible* systems on the map.
Requires either `?map_id=<UUID>` or `?slug=<map-slug>`.
Optional hours_ago
Example:
GET /api/map/systems_kills?map_id=<uuid>
GET /api/map/systems_kills?slug=<map-slug>
GET /api/map/systems_kills?map_id=<uuid>&hour_ago=<somehours>
"""
def list_systems_kills(conn, params) do
with {:ok, map_id} <- Util.fetch_map_id(params),
# fetch visible systems from the repo
{:ok, systems} <- MapSystemRepo.get_visible_by_map(map_id) do
Logger.debug(fn -> "[list_systems_kills] Found #{length(systems)} visible systems for map_id=#{map_id}" end)
# Parse the hours_ago param
hours_ago = parse_hours_ago(params["hours_ago"])
# Gather system IDs
solar_ids = Enum.map(systems, & &1.solar_system_id)
# Fetch kills for each system from the cache
kills_map = KillsCache.fetch_cached_kills_for_systems(solar_ids)
# Build final JSON data
data =
Enum.map(systems, fn sys ->
kills = Map.get(kills_map, sys.solar_system_id, [])
# Filter out kills older than hours_ago
filtered_kills = maybe_filter_kills_by_time(kills, hours_ago)
Logger.debug(fn -> "
[list_systems_kills] For system_id=#{sys.solar_system_id},
found #{length(kills)} kills total,
returning #{length(filtered_kills)} kills after hours_ago filter
" end)
%{
solar_system_id: sys.solar_system_id,
kills: filtered_kills
}
end)
json(conn, %{data: data})
else
{:error, msg} when is_binary(msg) ->
Logger.warn("[list_systems_kills] Bad request: #{msg}")
conn
|> put_status(:bad_request)
|> json(%{error: msg})
{:error, reason} ->
Logger.error("[list_systems_kills] Could not fetch systems: #{inspect(reason)}")
conn
|> put_status(:not_found)
|> json(%{error: "Could not fetch systems: #{inspect(reason)}"})
end
end
# If hours_str is present and valid, parse it. Otherwise return nil (no filter).
defp parse_hours_ago(nil), do: nil
defp parse_hours_ago(hours_str) do
case Integer.parse(hours_str) do
{num, ""} when num > 0 -> num
_ -> nil
end
end
defp maybe_filter_kills_by_time(kills, hours_ago) when is_integer(hours_ago) do
cutoff = DateTime.utc_now() |> DateTime.add(-hours_ago * 3600, :second)
Enum.filter(kills, fn kill ->
kill_time = kill["kill_time"]
case kill_time do
%DateTime{} = dt ->
# Keep kills that occurred after the cutoff
DateTime.compare(dt, cutoff) != :lt
# If it's something else (nil, or a weird format), skip
_ ->
false
end
end)
end
# If hours_ago is nil, maybe no time filtering:
defp maybe_filter_kills_by_time(kills, nil), do: kills
defp handle_all_structure_timers(conn, map_id) do
case MapSystemRepo.get_visible_by_map(map_id) do
{:ok, systems} ->
@@ -268,11 +340,8 @@ defmodule WandererAppWeb.APIController do
defp get_tracked_by_map_ids(map_id) do
case MapCharacterSettingsRepo.get_tracked_by_map_all(map_id) do
{:ok, settings_list} ->
{:ok, settings_list}
{:error, reason} ->
{:error, :get_tracked_error, reason}
{:ok, settings_list} -> {:ok, settings_list}
{:error, reason} -> {:error, :get_tracked_error, reason}
end
end
@@ -286,38 +355,6 @@ defmodule WandererAppWeb.APIController do
end
end
defp fetch_map_id(%{"map_id" => mid}) when is_binary(mid) and mid != "" do
{:ok, mid}
end
defp fetch_map_id(%{"slug" => slug}) when is_binary(slug) and slug != "" do
case WandererApp.Api.Map.get_map_by_slug(slug) do
{:ok, map} ->
{:ok, map.id}
{:error, _reason} ->
{:error, "No map found for slug=#{slug}"}
end
end
defp fetch_map_id(_),
do: {:error, "Must provide either ?map_id=UUID or ?slug=SLUG"}
defp require_param(params, key) do
case params[key] do
nil -> {:error, "Missing required param: #{key}"}
"" -> {:error, "Param #{key} cannot be empty"}
val -> {:ok, val}
end
end
defp parse_int(str) do
case Integer.parse(str) do
{num, ""} -> {:ok, num}
_ -> {:error, "Invalid integer for param id=#{str}"}
end
end
defp read_characters_by_ids(ids) when is_list(ids) do
if ids == [] do
{:ok, []}
@@ -366,30 +403,4 @@ defmodule WandererAppWeb.APIController do
:updated_at
])
end
defp static_system_to_json(system) do
system
|> Map.take([
:solar_system_id,
:region_id,
:constellation_id,
:solar_system_name,
:solar_system_name_lc,
:constellation_name,
:region_name,
:system_class,
:security,
:type_description,
:class_title,
:is_shattered,
:effect_name,
:effect_power,
:statics,
:wandering,
:triglavian_invasion_status,
:sun_type_id
])
end
end

View File

@@ -0,0 +1,15 @@
defmodule WandererAppWeb.Plugs.CheckKillsDisabled do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
if WandererApp.Env.zkill_preload_disabled?() do
conn
|> send_resp(403, "Map kill feed is disabled")
|> halt()
else
conn
end
end
end

View File

@@ -0,0 +1,41 @@
defmodule WandererAppWeb.UtilAPIController do
@moduledoc """
Utility functions for parameter handling, fetch helpers, etc.
"""
alias WandererApp.Api
def fetch_map_id(%{"map_id" => mid}) when is_binary(mid) and mid != "" do
{:ok, mid}
end
def fetch_map_id(%{"slug" => slug}) when is_binary(slug) and slug != "" do
case Api.Map.get_map_by_slug(slug) do
{:ok, map} ->
{:ok, map.id}
{:error, _reason} ->
{:error, "No map found for slug=#{slug}"}
end
end
def fetch_map_id(_),
do: {:error, "Must provide either ?map_id=UUID or ?slug=SLUG"}
# Require a given param to be present and non-empty
def require_param(params, key) do
case params[key] do
nil -> {:error, "Missing required param: #{key}"}
"" -> {:error, "Param #{key} cannot be empty"}
val -> {:ok, val}
end
end
# Parse a string into an integer
def parse_int(str) do
case Integer.parse(str) do
{num, ""} -> {:ok, num}
_ -> {:error, "Invalid integer for param id=#{str}"}
end
end
end

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

@@ -130,7 +130,7 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
if delete_connection_with_sigs && not is_nil(s.linked_system_id) do
map_id
|> WandererApp.Map.Server.delete_connection(%{
solar_system_source_id: solar_system_id |> String.to_integer(),
solar_system_source_id: system.solar_system_id,
solar_system_target_id: s.linked_system_id
})
end
@@ -180,7 +180,7 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
character_id: first_tracked_character.id,
user_id: current_user.id,
map_id: map_id,
solar_system_id: solar_system_id,
solar_system_id: system.solar_system_id,
signatures: added_signatures_eve_ids
})
end
@@ -191,7 +191,7 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
character_id: first_tracked_character.id,
user_id: current_user.id,
map_id: map_id,
solar_system_id: solar_system_id,
solar_system_id: system.solar_system_id,
signatures: removed_signatures_eve_ids
})
end

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

@@ -114,33 +114,39 @@ defmodule WandererAppWeb.Router do
plug WandererAppWeb.Plugs.CheckMapApiKey
end
scope "/api/map", WandererAppWeb do
pipe_through [:api_map]
pipe_through [:api]
pipeline :api_kills do
plug WandererAppWeb.Plugs.CheckApiDisabled
end
# GET /api/map/systems?map_id=... or ?slug=...
get "/systems", APIController, :list_systems
scope "/api/map/systems-kills", WandererAppWeb do
pipe_through [:api, :api_map, :api_kills]
# GET /api/map/system-static-info?id=... plus either map_id=... or slug=...
get "/system-static-info", APIController, :show_system_static
get "/", MapAPIController, :list_systems_kills
end
# GET /api/map/system?id=... plus either map_id=... or slug=...
get "/system", APIController, :show_system
scope "/api/map", WandererAppWeb do
pipe_through [:api, :api_map]
# GET /api/map/characters?map_id=... or slug=...
get "/characters", APIController, :tracked_characters_with_info
# GET /api/map/systems?map_id=... or ?slug=...
get "/systems", MapAPIController, :list_systems
# GET /api/map/structure-timers?map_id=... or slug=... and optionally ?system_id=...
get "/structure-timers", APIController, :show_structure_timers
end
# GET /api/map/system?id=... plus either map_id=... or slug=...
get "/system", MapAPIController, :show_system
scope "/api/common", WandererAppWeb do
pipe_through [:api]
# GET /api/map/characters?map_id=... or slug=...
get "/characters", MapAPIController, :tracked_characters_with_info
# GET /api/common/system-static-info?id=...
get "/system-static-info", APIController, :show_system_static
# GET /api/map/structure-timers?map_id=... or slug=... and optionally ?system_id=...
get "/structure-timers", MapAPIController, :show_structure_timers
end
end
scope "/api/common", WandererAppWeb do
pipe_through [:api]
# GET /api/common/system-static-info?id=...
get "/system-static-info", CommonAPIController, :show_system_static
end
scope "/", WandererAppWeb do
pipe_through [:browser, :blog, :redirect_if_user_is_authenticated]

View File

@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
@source_url "https://github.com/wanderer-industries/wanderer"
@version "1.43.2"
@version "1.44.3"
def project do
[
@@ -34,7 +34,7 @@ defmodule WandererApp.MixProject do
def application do
[
mod: {WandererApp.Application, []},
extra_applications: [:logger, :runtime_tools]
extra_applications: [:logger, :runtime_tools, :ex_rated]
]
end
@@ -54,6 +54,8 @@ defmodule WandererApp.MixProject do
{:sobelow, ">= 0.0.0", only: [:dev], runtime: false},
{:mix_audit, ">= 0.0.0", only: [:dev], runtime: false},
{:ex_check, "~> 0.14.0", only: [:dev], runtime: false},
{:ex_rated, "~> 2.0"},
{:retry, "~> 0.18.0"},
{:phoenix, "~> 1.7.12"},
{:phoenix_ecto, "~> 4.6"},
{:ecto_sql, "~> 3.10"},

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

@@ -199,6 +199,101 @@ No api key is required for routes that being with /api/common
```
---
### 4. Kills Activity
GET /api/map/systems-kills?map_id=<UUID>
GET /api/map/systems-kills?slug=<map-slug>"
- **Description:** Retrieves the kill activity for the specified map (by `map_id` or `slug`), including details on the attacker and victim
#### Example Request
```
curl -H "Authorization: Bearer <REDACTED_TOKEN>" "https://wanderer.example.com/api/map/systems-kills?slug==some-slug"
```
#### Example Response
```
{
"data": [
{
"kills": [
{
"attacker_count": 1,
"final_blow_alliance_id": 99013806,
"final_blow_alliance_ticker": "TCE",
"final_blow_char_id": 2116802670,
"final_blow_char_name": "Bambi Bunny",
"final_blow_corp_id": 98140648,
"final_blow_corp_ticker": "GNK3D",
"final_blow_ship_name": "Thrasher",
"final_blow_ship_type_id": 16242,
"kill_time": "2025-01-21T21:00:59Z",
"killmail_id": 124181782,
"npc": false,
"solar_system_id": 30002768,
"total_value": 10000,
"victim_alliance_id": null,
"victim_char_id": 2121725410,
"victim_char_name": "Bill Drummond",
"victim_corp_id": 98753095,
"victim_corp_ticker": "KSTJK",
"victim_ship_name": "Capsule",
"victim_ship_type_id": 670,
"zkb": {
"awox": false,
"destroyedValue": 10000,
"droppedValue": 0,
"fittedValue": 10000,
"hash": "777148f8bf344bade68a6a0821bfe0a37491a7a6",
"labels": ["cat:6","#:1","pvp","loc:highsec"],
"locationID": 50014064,
"npc": false,
"points": 1,
"solo": false,
"totalValue": 10000
}
},
{
"attacker_count": 3,
"final_blow_alliance_id": null,
"final_blow_char_id": null,
"final_blow_corp_id": null,
"final_blow_ship_type_id": 3740,
"kill_time": "2025-01-21T21:00:38Z",
"killmail_id": 124181769,
"npc": true,
"solar_system_id": 30002768,
"total_value": 2656048.48,
"victim_alliance_id": 99013806,
"victim_alliance_ticker": "TCE",
"victim_char_id": 2116802745,
"victim_char_name": "Brittni Bunny",
"victim_corp_id": 98140648,
"victim_corp_ticker": "GNK3D",
"victim_ship_name": "Coercer",
"victim_ship_type_id": 16236,
"zkb": {
"awox": false,
"destroyedValue": 2509214.44,
"droppedValue": 146834.04,
"fittedValue": 2607449.82,
"hash": "d3dd6b8833b2a9d36dd5a3eecf9838c4c8b01acd",
"labels": ["cat:6","#:2+","npc","loc:highsec"],
"locationID": 50014064,
"npc": true,
"points": 1,
"solo": false,
"totalValue": 2656048.48
}
}
],
"solar_system_id": 30002768
},
...
]
}
```
---
## Conclusion
Using these APIs, you can programmatically retrieve system and character information from your map. Whether youre building a custom analytics dashboard, a corp management tool, or just want to explore data outside the standard UI, these endpoints provide a straightforward way to fetch up-to-date map details.

View File

@@ -1,7 +1,7 @@
%{
title: "Managing Upwell Structures & Timers with the Structures Widget",
author: "Wanderer Team",
cover_image_uri: "/images/news/structure-widget/cover.png",
cover_image_uri: "/images/news/01-20-structure-widget/cover.png",
tags: ~w(interface guide map structures),
description: "Learn how to track structure information using the Structures Widget."
}
@@ -22,7 +22,7 @@ In this guide, well explore how to enable the Structures Widget, manage struc
### 1. Enabling the Structure Widget
![Enabling the Structures Widget](/images/news/structure-widget/enable-widget.png "Enable Structures Widget")
![Enabling the Structures Widget](/images/news/01-20-structure-widget/enable-widget.png "Enable Structures Widget")
1. **Open the Map:**
2. **Locate the Widget Settings:** By default, the structure widget panel is not visible. Enable it by going to menu -> map settings -> widgets.
@@ -34,7 +34,7 @@ In this guide, well explore how to enable the Structures Widget, manage struc
### 2. Overview of the Structures Widget
![Structures Widget Overview](/images/news/01-20-structure-widget/widget.png "Structures Widget")
![Structures Widget Overview](/images/news/01-20-structure-widget/cover.png "Structures Widget")
Once enabled, the **Structures Widget** appears in the map. It shows:

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
}