mirror of
https://github.com/wanderer-industries/wanderer
synced 2025-12-01 21:42:37 +00:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4c40d7542 | ||
|
|
04f3fec0c0 | ||
|
|
cd0b4b0fc9 | ||
|
|
e7b115e6e6 | ||
|
|
dff8fc6396 | ||
|
|
afdaeb3d34 | ||
|
|
ac6053361e | ||
|
|
eb3e1ba3aa | ||
|
|
8468a9b5de | ||
|
|
5eafe59dcb | ||
|
|
b38bcaa8cf | ||
|
|
8a238a447d | ||
|
|
3731219216 | ||
|
|
73d5fd5f67 | ||
|
|
e8e4aed6d5 | ||
|
|
63571a462f | ||
|
|
606add4142 | ||
|
|
dac480b059 | ||
|
|
5f67cb1dd7 | ||
|
|
5886fff753 | ||
|
|
da2e12bdd1 | ||
|
|
05c3d20e56 | ||
|
|
4633d26517 | ||
|
|
30b0556d47 | ||
|
|
e094378dc5 | ||
|
|
0c48189503 | ||
|
|
a5c346627a | ||
|
|
4e526040bf | ||
|
|
869c25cd60 | ||
|
|
6aac698cd8 | ||
|
|
230016b90f | ||
|
|
4b1aef8dd9 | ||
|
|
d34509d7a0 | ||
|
|
fca98ec232 | ||
|
|
e2814e95bd | ||
|
|
68a3f84704 | ||
|
|
4bc76feefc | ||
|
|
da39a55fd0 | ||
|
|
ee3cf04cd4 | ||
|
|
d79e7fe2ff | ||
|
|
8de9fdef32 | ||
|
|
f51deeec2d | ||
|
|
a971c69a96 | ||
|
|
b7995f50de | ||
|
|
14997a2959 | ||
|
|
8fef6bcf82 | ||
|
|
1f82d23963 | ||
|
|
28317a2431 | ||
|
|
6aac496a57 | ||
|
|
ac9306b713 | ||
|
|
d55e804efa | ||
|
|
08407a5679 | ||
|
|
c37d175bec | ||
|
|
69c5326e72 | ||
|
|
305f63e11d | ||
|
|
698fd5e083 | ||
|
|
1af8342d30 | ||
|
|
68b59da78e | ||
|
|
e784a3f850 | ||
|
|
a45e2f3fc2 | ||
|
|
8a3d920c31 |
@@ -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"
|
||||
|
||||
15
.github/workflows/build.yml
vendored
15
.github/workflows/build.yml
vendored
@@ -78,22 +78,23 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: 😅 Cache deps
|
||||
id: cache-deps
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
env:
|
||||
cache-name: cache-elixir-deps
|
||||
with:
|
||||
path: deps
|
||||
key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }}
|
||||
path: |
|
||||
deps
|
||||
key: ${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-mix-${{ env.cache-name }}-
|
||||
${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-
|
||||
- name: 😅 Cache compiled build
|
||||
id: cache-build
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
env:
|
||||
cache-name: cache-compiled-build
|
||||
with:
|
||||
path: |
|
||||
**/_build
|
||||
_build
|
||||
key: ${{ runner.os }}-build-${{ hashFiles('**/mix.lock') }}-${{ hashFiles( '**/lib/**/*.{ex,eex}', '**/config/*.exs', '**/mix.exs' ) }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-${{ hashFiles('**/mix.lock') }}-
|
||||
@@ -187,6 +188,8 @@ jobs:
|
||||
push: true
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,"name=${{ env.REGISTRY_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
227
CHANGELOG.md
227
CHANGELOG.md
@@ -2,6 +2,233 @@
|
||||
|
||||
<!-- changelog -->
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* prevent constraint error for follow/toggle (#132)
|
||||
|
||||
## [v1.43.1](https://github.com/wanderer-industries/wanderer/compare/v1.43.0...v1.43.1) (2025-01-20)
|
||||
|
||||
|
||||
|
||||
|
||||
## [v1.43.0](https://github.com/wanderer-industries/wanderer/compare/v1.42.5...v1.43.0) (2025-01-20)
|
||||
|
||||
|
||||
|
||||
|
||||
### Features:
|
||||
|
||||
* add news post for structures widget (#131)
|
||||
|
||||
## [v1.42.5](https://github.com/wanderer-industries/wanderer/compare/v1.42.4...v1.42.5) (2025-01-20)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Map: Fix link signatures on splash. Fix deleting connection on locked system remove.
|
||||
|
||||
## [v1.42.4](https://github.com/wanderer-industries/wanderer/compare/v1.42.3...v1.42.4) (2025-01-20)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Fix system statics list (required EVE DB data update). Add system name to signature added/removed audit log
|
||||
|
||||
## [v1.42.3](https://github.com/wanderer-industries/wanderer/compare/v1.42.2...v1.42.3) (2025-01-17)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* change structure tooltip to avoid paste confusion (#125)
|
||||
|
||||
* change structure tooltip to avoid paste confusion
|
||||
|
||||
* clarify use of evetime and use primereact calendar
|
||||
|
||||
## [v1.42.2](https://github.com/wanderer-industries/wanderer/compare/v1.42.1...v1.42.2) (2025-01-16)
|
||||
|
||||
|
||||
|
||||
|
||||
## [v1.42.1](https://github.com/wanderer-industries/wanderer/compare/v1.42.0...v1.42.1) (2025-01-16)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Map: Remove linked sig ID if system containing signature removed from map
|
||||
|
||||
## [v1.42.0](https://github.com/wanderer-industries/wanderer/compare/v1.41.0...v1.42.0) (2025-01-16)
|
||||
|
||||
|
||||
|
||||
|
||||
### Features:
|
||||
|
||||
* Audit: Add 'Signatures added/removed' map audit events
|
||||
|
||||
## [v1.41.0](https://github.com/wanderer-industries/wanderer/compare/v1.40.7...v1.41.0) (2025-01-16)
|
||||
|
||||
|
||||
|
||||
|
||||
### Features:
|
||||
|
||||
* Audit: Add 'ACL added/removed' map audit events
|
||||
|
||||
## [v1.40.7](https://github.com/wanderer-industries/wanderer/compare/v1.40.6...v1.40.7) (2025-01-15)
|
||||
|
||||
|
||||
|
||||
|
||||
## [v1.40.6](https://github.com/wanderer-industries/wanderer/compare/v1.40.5...v1.40.6) (2025-01-15)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Map: Fix follow mode
|
||||
|
||||
* center system is not selected text for structures (#122)
|
||||
|
||||
* Map: Fix system revert issues
|
||||
|
||||
* Map: Fix issues with splashing signatures select & sig ID in temp names
|
||||
|
||||
## [v1.40.5](https://github.com/wanderer-industries/wanderer/compare/v1.40.4...v1.40.5) (2025-01-14)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Map: Fix follow mode
|
||||
|
||||
## [v1.40.4](https://github.com/wanderer-industries/wanderer/compare/v1.40.3...v1.40.4) (2025-01-14)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* center system is not selected text for structures (#122)
|
||||
|
||||
## [v1.40.3](https://github.com/wanderer-industries/wanderer/compare/v1.40.2...v1.40.3) (2025-01-14)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Map: Fix system revert issues
|
||||
|
||||
## [v1.40.2](https://github.com/wanderer-industries/wanderer/compare/v1.40.1...v1.40.2) (2025-01-14)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Map: Fix issues with splashing signatures select & sig ID in temp names
|
||||
|
||||
## [v1.40.1](https://github.com/wanderer-industries/wanderer/compare/v1.40.0...v1.40.1) (2025-01-14)
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ body {
|
||||
width: 400px; /* As IE6 ignores !important it will set width as 400px; */
|
||||
}
|
||||
|
||||
body > div:first-of-type {
|
||||
min-height: 500px !important;
|
||||
}
|
||||
|
||||
.lending-normal {
|
||||
font-family: 'Shentox', 'Rogan', sans-serif !important;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -112,3 +112,28 @@
|
||||
.p-autocomplete .p-autocomplete-multiple-container .p-autocomplete-token {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
/* Fixed sizes of Input switch */
|
||||
.p-inputswitch {
|
||||
width: 2.0rem;
|
||||
height: 1.15rem;
|
||||
|
||||
.p-inputswitch-slider:before {
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
left: 0.14rem;
|
||||
margin-top: -0.385rem;
|
||||
}
|
||||
|
||||
&.p-highlight .p-inputswitch-slider:before {
|
||||
transform: translateX(0.8rem);
|
||||
}
|
||||
|
||||
&:not(.p-disabled):has(.p-inputswitch-input:hover) .p-inputswitch-slider {
|
||||
background: rgb(255 255 255 / 21%);
|
||||
}
|
||||
|
||||
&.p-highlight .p-inputswitch-slider {
|
||||
background: #966d3d;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ForwardedRef, forwardRef, MouseEvent, useCallback, useEffect, useMemo }
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Edge,
|
||||
EdgeChange,
|
||||
MiniMap,
|
||||
Node,
|
||||
NodeChange,
|
||||
@@ -83,6 +84,7 @@ interface MapCompProps {
|
||||
onCommand: OutCommandHandler;
|
||||
onSelectionChange: OnMapSelectionChange;
|
||||
onManualDelete(systems: string[]): void;
|
||||
canRemoveConnection?(connectionId: string): boolean;
|
||||
onConnectionInfoClick?(e: SolarSystemConnection): void;
|
||||
onAddSystem?: OnMapAddSystemCallback;
|
||||
onSelectionContextMenu?: NodeSelectionMouseHandler;
|
||||
@@ -112,8 +114,9 @@ const MapComp = ({
|
||||
isSoftBackground,
|
||||
theme,
|
||||
onAddSystem,
|
||||
canRemoveConnection,
|
||||
}: MapCompProps) => {
|
||||
const { getNode, getNodes } = useReactFlow();
|
||||
const { getEdge, getNode, getNodes } = useReactFlow();
|
||||
const [nodes, , onNodesChange] = useNodesState<Node<SolarSystemRawType>>(initialNodes);
|
||||
const [edges, , onEdgesChange] = useEdgesState<Edge<SolarSystemConnection>>(initialEdges);
|
||||
|
||||
@@ -222,6 +225,40 @@ const MapComp = ({
|
||||
[getNode, getNodes, onManualDelete, onNodesChange],
|
||||
);
|
||||
|
||||
const handleEdgesChange = useCallback(
|
||||
(changes: EdgeChange[]) => {
|
||||
const nextChanges = changes.reduce((acc, change) => {
|
||||
if (change.type !== 'remove') {
|
||||
return [...acc, change];
|
||||
}
|
||||
|
||||
if (canRemoveConnection?.(change.id)) {
|
||||
return [...acc, change];
|
||||
}
|
||||
|
||||
const edge = getEdge(change.id);
|
||||
if (!edge) {
|
||||
return [...acc, change];
|
||||
}
|
||||
|
||||
const sourceNode = getNode(edge.source);
|
||||
const targetNode = getNode(edge.target);
|
||||
if (!sourceNode || !targetNode) {
|
||||
return [...acc, change];
|
||||
}
|
||||
|
||||
if (sourceNode.data.locked || targetNode.data.locked) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
return [...acc, change];
|
||||
}, [] as EdgeChange[]);
|
||||
|
||||
onEdgesChange(nextChanges);
|
||||
},
|
||||
[getEdge, getNode, onEdgesChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
update(x => ({
|
||||
...x,
|
||||
@@ -237,7 +274,7 @@ const MapComp = ({
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={onConnect}
|
||||
// TODO we need save into session all of this
|
||||
// and on any action do either
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { WormholeClassComp } from '@/hooks/Mapper/components/map/components/WormholeClassComp';
|
||||
import { UnsplashedSignature } from '@/hooks/Mapper/components/map/components/UnsplashedSignature';
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>) => {
|
||||
const nodeVars = useSolarSystemNode(props);
|
||||
|
||||
|
||||
@@ -1,274 +1,39 @@
|
||||
@import '@/hooks/Mapper/components/map/styles/eve-common-variables';
|
||||
@import './SolarSystemNodeDefault.module.scss';
|
||||
|
||||
$pastel-blue: #5a7d9a;
|
||||
$pastel-pink: #d291bc;
|
||||
$pastel-green: #88b04b;
|
||||
$pastel-yellow: #ffdd59;
|
||||
$dark-bg: #2d2d2d;
|
||||
$text-color: #ffffff;
|
||||
$tooltip-bg: #202020;
|
||||
/* ---------------------------
|
||||
Only override what's different
|
||||
--------------------------- */
|
||||
|
||||
/* 1) .RootCustomNode:
|
||||
- new background-color using CSS var
|
||||
- plus color, font-family, and font-weight */
|
||||
.RootCustomNode {
|
||||
display: flex;
|
||||
width: 130px;
|
||||
height: 34px;
|
||||
|
||||
flex-direction: column;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
|
||||
background-color: var(--rf-node-bg-color, #202020) !important;
|
||||
color: var(--rf-text-color, #ffffff);
|
||||
font-family: var(--rf-node-font-family, inherit) !important;
|
||||
font-weight: var(--rf-node-font-weight, inherit) !important;
|
||||
|
||||
box-shadow: 0 0 5px rgba($dark-bg, 0.5);
|
||||
border: 1px solid darken($pastel-blue, 10%);
|
||||
border-radius: 5px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
|
||||
&.Mataria,
|
||||
&.Amarria,
|
||||
&.Gallente,
|
||||
&.Caldaria {
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-size: cover;
|
||||
background-position: 50% 50%;
|
||||
z-index: -1;
|
||||
background-repeat: no-repeat;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
&.Mataria {
|
||||
&::before {
|
||||
background-image: url('/images/mataria-180.png');
|
||||
opacity: 0.6;
|
||||
background-position-x: 1px;
|
||||
background-position-y: -14px;
|
||||
}
|
||||
}
|
||||
|
||||
&.Caldaria {
|
||||
&::before {
|
||||
background-image: url('/images/caldaria-180.png');
|
||||
opacity: 0.6;
|
||||
background-position-x: 1px;
|
||||
background-position-y: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
&.Amarria {
|
||||
&::before {
|
||||
opacity: 0.45;
|
||||
background-image: url('/images/amarr-180.png');
|
||||
background-position-x: 0;
|
||||
background-position-y: -13px;
|
||||
}
|
||||
}
|
||||
|
||||
&.Gallente {
|
||||
&::before {
|
||||
opacity: 0.5;
|
||||
background-image: url('/images/gallente-180.png');
|
||||
background-position-x: 1px;
|
||||
background-position-y: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: $pastel-pink;
|
||||
box-shadow: 0 0 10px #9a1af1c2;
|
||||
}
|
||||
|
||||
&.tooltip {
|
||||
background-color: $tooltip-bg;
|
||||
color: $text-color;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid $pastel-pink;
|
||||
}
|
||||
|
||||
&.eve-system-status-home {
|
||||
border: 1px solid var(--eve-solar-system-status-color-home-dark30);
|
||||
background-image: linear-gradient(
|
||||
275deg,
|
||||
var(--eve-solar-system-status-home),
|
||||
transparent
|
||||
);
|
||||
&.selected {
|
||||
border-color: var(--eve-solar-system-status-color-home);
|
||||
}
|
||||
}
|
||||
|
||||
&.eve-system-status-friendly {
|
||||
border: 1px solid var(--eve-solar-system-status-color-friendly-dark20);
|
||||
background-image: linear-gradient(
|
||||
275deg,
|
||||
var(--eve-solar-system-status-friendly-dark30),
|
||||
transparent
|
||||
);
|
||||
&.selected {
|
||||
border-color: var(--eve-solar-system-status-color-friendly-dark5);
|
||||
}
|
||||
}
|
||||
|
||||
&.eve-system-status-lookingFor {
|
||||
border: 1px solid var(--eve-solar-system-status-color-lookingFor-dark15);
|
||||
background-image: linear-gradient(275deg, #45ff8f2f, #457fff2f);
|
||||
&.selected {
|
||||
border-color: $pastel-pink;
|
||||
}
|
||||
}
|
||||
|
||||
&.eve-system-status-warning {
|
||||
background-image: linear-gradient(
|
||||
275deg,
|
||||
var(--eve-solar-system-status-warning),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&.eve-system-status-dangerous {
|
||||
background-image: linear-gradient(
|
||||
275deg,
|
||||
var(--eve-solar-system-status-dangerous),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&.eve-system-status-target {
|
||||
background-image: linear-gradient(
|
||||
275deg,
|
||||
var(--eve-solar-system-status-target),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* 2) .Bookmarks:
|
||||
- add var-based font family/weight
|
||||
*/
|
||||
.Bookmarks {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
left: 4px;
|
||||
font-family: var(--rf-node-font-family, inherit) !important;
|
||||
font-weight: var(--rf-node-font-weight, inherit) !important;
|
||||
|
||||
|
||||
& > .Bookmark {
|
||||
min-width: 13px;
|
||||
height: 22px;
|
||||
position: relative;
|
||||
top: -13px;
|
||||
border-radius: 5px;
|
||||
color: #ffffff;
|
||||
font-size: 8px;
|
||||
text-align: center;
|
||||
padding-top: 2px;
|
||||
font-weight: bolder;
|
||||
padding-left: 3px;
|
||||
padding-right: 3px;
|
||||
|
||||
//background-color: #833ca4;
|
||||
|
||||
&:not(:first-child) {
|
||||
box-shadow: inset 4px -3px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.BookmarkWithIcon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: -2px;
|
||||
text-shadow: 0 0 3px rgba(0, 0, 0, 1);
|
||||
padding-right: 2px;
|
||||
|
||||
& > .icon {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
& > .text {
|
||||
margin-top: 1px;
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Unsplashed {
|
||||
position: absolute;
|
||||
width: calc(50% - 4px);
|
||||
z-index: -1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
left: 2px;
|
||||
|
||||
&--right {
|
||||
left: calc(50% + 6px);
|
||||
}
|
||||
|
||||
& > .Signature {
|
||||
width: 13px;
|
||||
height: 4px;
|
||||
position: relative;
|
||||
top: 3px;
|
||||
border-radius: 5px;
|
||||
color: #ffffff;
|
||||
font-size: 8px;
|
||||
text-align: center;
|
||||
padding-top: 2px;
|
||||
font-weight: bolder;
|
||||
padding-left: 3px;
|
||||
padding-right: 3px;
|
||||
display: block;
|
||||
|
||||
background-color: #833ca4;
|
||||
|
||||
&:not(:first-child) {
|
||||
box-shadow: inset 4px -3px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
/* 3) .HeadRow, .classTitle, .classSystemName:
|
||||
- add new references to var-based font family/weight
|
||||
*/
|
||||
.HeadRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
font-family: var(--rf-node-font-family, inherit) !important;
|
||||
font-weight: var(--rf-node-font-weight, inherit) !important;
|
||||
|
||||
.classTitle {
|
||||
font-size: 11px;
|
||||
font-family: var(--rf-node-font-family, inherit) !important;
|
||||
font-weight: var(--rf-node-font-weight, inherit) !important;
|
||||
text-shadow: 0 0 2px rgb(0 0 0 / 73%);
|
||||
}
|
||||
|
||||
|
||||
/* Firefox kostyl */
|
||||
@-moz-document url-prefix() {
|
||||
.classSystemName {
|
||||
font-family: var(--rf-node-font-family, inherit) !important;
|
||||
@@ -280,20 +45,15 @@ $tooltip-bg: #202020;
|
||||
font-family: var(--rf-node-font-family, inherit) !important;
|
||||
font-weight: var(--rf-node-font-weight, inherit) !important;
|
||||
}
|
||||
|
||||
.solarSystemName {
|
||||
}
|
||||
}
|
||||
|
||||
/* 4) .BottomRow:
|
||||
- introduces .tagTitle, .regionName, .customName, .localCounter
|
||||
referencing new CSS variables */
|
||||
.BottomRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 19px;
|
||||
font-family: var(--rf-node-font-family, inherit) !important;
|
||||
font-weight: var(--rf-node-font-weight, inherit) !important;
|
||||
|
||||
|
||||
.tagTitle {
|
||||
font-size: 11px;
|
||||
font-weight: medium;
|
||||
@@ -327,100 +87,5 @@ $tooltip-bg: #202020;
|
||||
font-family: var(--rf-node-font-family, inherit) !important;
|
||||
font-weight: var(--rf-node-font-weight, inherit) !important;
|
||||
}
|
||||
|
||||
& > i {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
& > span {
|
||||
font-size: 9px;
|
||||
line-height: 9px;
|
||||
font-weight: 500;
|
||||
//margin-top: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.effect {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: -2px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.statics {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
font-size: 8px;
|
||||
|
||||
& > * {
|
||||
line-height: 10px;
|
||||
}
|
||||
|
||||
/* Firefox kostyl */
|
||||
@-moz-document url-prefix() {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.Handlers {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.Handle {
|
||||
min-width: initial;
|
||||
min-height: initial;
|
||||
border: 1px solid $pastel-blue;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
|
||||
&.selected {
|
||||
border-color: $pastel-pink;
|
||||
}
|
||||
|
||||
&.HandleTop {
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
&.HandleRight {
|
||||
right: -2px;
|
||||
}
|
||||
|
||||
&.HandleBottom {
|
||||
bottom: -2px;
|
||||
}
|
||||
|
||||
&.HandleLeft {
|
||||
left: -2px;
|
||||
}
|
||||
|
||||
&.Tick {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
|
||||
&.HandleTop {
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
&.HandleRight {
|
||||
right: -3px;
|
||||
}
|
||||
|
||||
&.HandleBottom {
|
||||
bottom: -3px;
|
||||
}
|
||||
|
||||
&.HandleLeft {
|
||||
left: -3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,4 +15,8 @@
|
||||
font-weight: bolder;
|
||||
display: block;
|
||||
}
|
||||
|
||||
& > .Eol {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import { WORMHOLE_CLASS_STYLES, WORMHOLES_ADDITIONAL_INFO } from '@/hooks/Mapper
|
||||
import { useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { renderInfoColumn } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/renders';
|
||||
|
||||
import { k162Types } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureK162TypeSelect';
|
||||
import { K162_TYPES_MAP } from '@/hooks/Mapper/constants.ts';
|
||||
import { parseSignatureCustomInfo } from '@/hooks/Mapper/helpers/parseSignatureCustomInfo.ts';
|
||||
|
||||
interface UnsplashedSignatureProps {
|
||||
signature: SystemSignature;
|
||||
@@ -22,17 +22,22 @@ export const UnsplashedSignature = ({ signature }: UnsplashedSignatureProps) =>
|
||||
const whData = useMemo(() => wormholesData[signature.type], [signature.type, wormholesData]);
|
||||
const whClass = useMemo(() => (whData ? WORMHOLES_ADDITIONAL_INFO[whData.dest] : null), [whData]);
|
||||
|
||||
const k162TypeOption = useMemo(() => {
|
||||
if (!signature.custom_info) {
|
||||
return null;
|
||||
}
|
||||
const customInfo = JSON.parse(signature.custom_info);
|
||||
if (!customInfo.k162Type) {
|
||||
return null;
|
||||
}
|
||||
return k162Types.find(x => x.value === customInfo.k162Type);
|
||||
const customInfo = useMemo(() => {
|
||||
return parseSignatureCustomInfo(signature.custom_info);
|
||||
}, [signature]);
|
||||
|
||||
const k162TypeOption = useMemo(() => {
|
||||
if (!customInfo?.k162Type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return K162_TYPES_MAP[customInfo.k162Type];
|
||||
}, [customInfo]);
|
||||
|
||||
const isEOL = useMemo(() => {
|
||||
return customInfo?.isEOL;
|
||||
}, [customInfo]);
|
||||
|
||||
const whClassStyle = useMemo(() => {
|
||||
if (signature.type === 'K162' && k162TypeOption) {
|
||||
const k162Data = wormholesData[k162TypeOption.whClassName];
|
||||
@@ -45,19 +50,19 @@ export const UnsplashedSignature = ({ signature }: UnsplashedSignatureProps) =>
|
||||
return (
|
||||
<WdTooltipWrapper
|
||||
className={clsx(classes.Signature)}
|
||||
// @ts-ignore
|
||||
content={
|
||||
(
|
||||
<div className="flex flex-col gap-1">
|
||||
<InfoDrawer title={<b className="text-slate-50">{signature.eve_id}</b>}>
|
||||
{renderInfoColumn(signature)}
|
||||
</InfoDrawer>
|
||||
</div>
|
||||
) as React.ReactNode
|
||||
<div className="flex flex-col gap-1">
|
||||
<InfoDrawer title={<b className="text-slate-50">{signature.eve_id}</b>}>
|
||||
{renderInfoColumn(signature)}
|
||||
</InfoDrawer>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={clsx(classes.Box, whClassStyle)}>
|
||||
<svg width="13" height="4" viewBox="0 0 13 4" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="13" height="4" rx="2" className={whClassStyle} fill="currentColor" />
|
||||
<svg width="13" height="8" viewBox="0 0 13 8" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect y="1" width="13" height="4" rx="2" className={whClassStyle} fill="currentColor" />
|
||||
{isEOL && <rect x="4" width="5" height="6" rx="1" className={clsx(classes.Eol)} fill="#a153ac" />}
|
||||
</svg>
|
||||
</div>
|
||||
</WdTooltipWrapper>
|
||||
|
||||
@@ -61,7 +61,7 @@ export const useMapHandlers = (ref: ForwardedRef<MapHandlers>, onSelectionChange
|
||||
setTimeout(() => mapAddSystems(data as CommandAddSystems), 100);
|
||||
break;
|
||||
case Commands.updateSystems:
|
||||
setTimeout(() => mapUpdateSystems(data as CommandUpdateSystems), 100);
|
||||
mapUpdateSystems(data as CommandUpdateSystems);
|
||||
break;
|
||||
case Commands.removeSystems:
|
||||
setTimeout(() => removeSystems(data as CommandRemoveSystems), 100);
|
||||
@@ -70,7 +70,7 @@ export const useMapHandlers = (ref: ForwardedRef<MapHandlers>, onSelectionChange
|
||||
setTimeout(() => addConnections(data as CommandAddConnections), 100);
|
||||
break;
|
||||
case Commands.removeConnections:
|
||||
removeConnections(data as CommandRemoveConnections);
|
||||
setTimeout(() => removeConnections(data as CommandRemoveConnections), 100);
|
||||
break;
|
||||
case Commands.charactersUpdated:
|
||||
charactersUpdated(data as CommandCharactersUpdated);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { isWormholeSpace } from '@/hooks/Mapper/components/map/helpers/isWormhol
|
||||
import { getSystemClassStyles, prepareUnsplashedChunks } from '@/hooks/Mapper/components/map/helpers';
|
||||
import { sortWHClasses } from '@/hooks/Mapper/helpers';
|
||||
import { LabelsManager } from '@/hooks/Mapper/utils/labelsManager';
|
||||
import { OutCommand } from '@/hooks/Mapper/types';
|
||||
import { CharacterTypeRaw, OutCommand } from '@/hooks/Mapper/types';
|
||||
import { LABELS_INFO, LABELS_ORDER } from '@/hooks/Mapper/components/map/constants';
|
||||
|
||||
function getActivityType(count: number) {
|
||||
@@ -33,7 +33,17 @@ function sortedLabels(labels: string[]) {
|
||||
|
||||
export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>) {
|
||||
const { id, data, selected } = props;
|
||||
const { system_static_info, system_signatures, locked, name, tag, status, labels, temporary_name } = data;
|
||||
const {
|
||||
system_static_info,
|
||||
system_signatures,
|
||||
locked,
|
||||
name,
|
||||
tag,
|
||||
status,
|
||||
labels,
|
||||
temporary_name,
|
||||
linked_sig_eve_id: linkedSigEveId = '',
|
||||
} = data;
|
||||
|
||||
const {
|
||||
system_class,
|
||||
@@ -71,7 +81,6 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>) {
|
||||
visibleNodes,
|
||||
showKSpaceBG,
|
||||
isThickConnections,
|
||||
linkedSigEveId,
|
||||
},
|
||||
outCommand,
|
||||
} = useMapState();
|
||||
@@ -132,12 +141,12 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (isShowLinkedSigIdTempName) {
|
||||
return temporary_name ? `${linkedSigPrefix}・${temporary_name}` : linkedSigPrefix;
|
||||
if (isShowLinkedSigIdTempName && linkedSigPrefix) {
|
||||
return temporary_name ? `${linkedSigPrefix}・${temporary_name}` : `${linkedSigPrefix}・${solar_system_name}`;
|
||||
}
|
||||
|
||||
return temporary_name;
|
||||
}, [isShowLinkedSigIdTempName, isTempSystemNameEnabled, linkedSigPrefix, temporary_name]);
|
||||
}, [isShowLinkedSigIdTempName, isTempSystemNameEnabled, linkedSigPrefix, solar_system_name, temporary_name]);
|
||||
|
||||
const systemName = useMemo(() => {
|
||||
if (isTempSystemNameEnabled && temporaryName) {
|
||||
@@ -146,7 +155,7 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>) {
|
||||
return solar_system_name;
|
||||
}, [isTempSystemNameEnabled, solar_system_name, temporaryName]);
|
||||
|
||||
const customName = (isTempSystemNameEnabled && temporaryName && name) || (solar_system_name !== name && name);
|
||||
const customName = (isTempSystemNameEnabled && temporaryName && name) || (solar_system_name !== name && name) || null;
|
||||
|
||||
const [unsplashedLeft, unsplashedRight] = useMemo(() => {
|
||||
if (!isShowUnsplashedSignatures) {
|
||||
@@ -203,3 +212,43 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>) {
|
||||
|
||||
return nodeVars;
|
||||
}
|
||||
|
||||
export interface SolarSystemNodeVars {
|
||||
id: string;
|
||||
selected: boolean;
|
||||
visible: boolean;
|
||||
isWormhole: boolean;
|
||||
classTitleColor: string | null;
|
||||
killsCount: number | null;
|
||||
killsActivityType: string | null;
|
||||
hasUserCharacters: boolean;
|
||||
showHandlers: boolean;
|
||||
regionClass: string | null;
|
||||
systemName: string;
|
||||
customName?: string | null;
|
||||
labelCustom: string | null;
|
||||
isShattered: boolean;
|
||||
tag?: string | null;
|
||||
status?: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
labelsInfo: Array<any>;
|
||||
dbClick: (event?: void) => void;
|
||||
sortedStatics: Array<string | number>;
|
||||
effectName: string | null;
|
||||
regionName: string | null;
|
||||
solarSystemId: number;
|
||||
solarSystemName: string | null;
|
||||
locked: boolean;
|
||||
hubs: string[] | number[];
|
||||
name: string | null;
|
||||
isConnecting: boolean;
|
||||
hoverNodeId: string | null;
|
||||
charactersInSystem: Array<CharacterTypeRaw>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
unsplashedLeft: Array<any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
unsplashedRight: Array<any>;
|
||||
isThickConnections: boolean;
|
||||
classTitle: string | null;
|
||||
temporaryName?: string | null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
@import './eve-common-variables';
|
||||
@import './eve-common';
|
||||
@import url('https://fonts.googleapis.com/css2?family=Oxygen:wght@300;400;700&display=swap');
|
||||
@@ -40,7 +39,6 @@
|
||||
--eve-wh-type-color-c13: #7986cb;
|
||||
--eve-wh-type-color-drifter: #44aa82;
|
||||
|
||||
|
||||
--rf-node-font-weight: bold;
|
||||
--rf-node-line-height: normal;
|
||||
--rf-node-font-family: 'Oxygen', sans-serif;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
|
||||
import { OutCommand } from '@/hooks/Mapper/types/mapHandlers.ts';
|
||||
@@ -58,7 +58,7 @@ export const SystemLinkSignatureDialog = ({ data, setVisible }: SystemLinkSignat
|
||||
<Dialog
|
||||
header="Select signature to link"
|
||||
visible
|
||||
draggable={false}
|
||||
draggable={true}
|
||||
style={{ width: '500px' }}
|
||||
onHide={handleHide}
|
||||
contentClassName="!p-0"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
SystemInfo,
|
||||
SystemSignatures,
|
||||
SystemStructures,
|
||||
SystemKills,
|
||||
} from '@/hooks/Mapper/components/mapInterface/widgets';
|
||||
|
||||
export const CURRENT_WINDOWS_VERSION = 8;
|
||||
@@ -16,6 +17,7 @@ export enum WidgetsIds {
|
||||
local = 'local',
|
||||
routes = 'routes',
|
||||
structures = 'structures',
|
||||
kills = 'kills',
|
||||
}
|
||||
|
||||
export const STORED_VISIBLE_WIDGETS_DEFAULT = [
|
||||
@@ -61,6 +63,13 @@ export const DEFAULT_WIDGETS: WindowProps[] = [
|
||||
zIndex: 0,
|
||||
content: () => <SystemStructures />,
|
||||
},
|
||||
{
|
||||
id: WidgetsIds.kills,
|
||||
position: { x: 270, y: 730 },
|
||||
size: { width: 510, height: 200 },
|
||||
zIndex: 0,
|
||||
content: () => <SystemKills />,
|
||||
},
|
||||
];
|
||||
|
||||
type WidgetsCheckboxesType = {
|
||||
@@ -89,4 +98,18 @@ export const WIDGETS_CHECKBOXES_PROPS: WidgetsCheckboxesType = [
|
||||
id: WidgetsIds.structures,
|
||||
label: 'Structures',
|
||||
},
|
||||
{
|
||||
id: WidgetsIds.kills,
|
||||
label: 'Kills',
|
||||
},
|
||||
];
|
||||
|
||||
export function getWidgetsCheckboxesProps(detailedKillsDisabled: boolean): WidgetsCheckboxesType {
|
||||
return filterOutKills(WIDGETS_CHECKBOXES_PROPS, detailedKillsDisabled);
|
||||
}
|
||||
|
||||
|
||||
function filterOutKills<T extends { id: WidgetsIds }>(items: T[], shouldFilter: boolean) {
|
||||
if (!shouldFilter) return items;
|
||||
return items.filter((w) => w.id !== WidgetsIds.kills);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { Widget } from '@/hooks/Mapper/components/mapInterface/components';
|
||||
import { SystemKillsContent } from './SystemKillsContent/SystemKillsContent';
|
||||
import { KillsHeader } from './components/SystemKillsHeader';
|
||||
import { useKillsWidgetSettings } from './hooks/useKillsWidgetSettings';
|
||||
import { useSystemKills } from './hooks/useSystemKills';
|
||||
import { KillsSettingsDialog } from './components/SystemKillsSettingsDialog';
|
||||
|
||||
export const SystemKills: React.FC = () => {
|
||||
const {
|
||||
data: { selectedSystems, systems },
|
||||
outCommand,
|
||||
} = useMapRootState();
|
||||
|
||||
const [systemId] = selectedSystems || [];
|
||||
|
||||
const [settingsDialogVisible, setSettingsDialogVisible] = useState(false);
|
||||
|
||||
const systemNameMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
systems.forEach(sys => {
|
||||
map[sys.id] = sys.temporary_name || sys.name || '???';
|
||||
});
|
||||
return map;
|
||||
}, [systems]);
|
||||
|
||||
const [settings] = useKillsWidgetSettings();
|
||||
const visible = settings.showAll;
|
||||
|
||||
const { kills, isLoading, error } = useSystemKills({
|
||||
systemId,
|
||||
outCommand,
|
||||
showAllVisible: visible,
|
||||
});
|
||||
|
||||
const isNothingSelected = !systemId && !visible;
|
||||
const showLoading = isLoading && kills.length === 0;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0">
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<Widget label={<KillsHeader systemId={systemId} onOpenSettings={() => setSettingsDialogVisible(true)} />}>
|
||||
{isNothingSelected && (
|
||||
<div className="w-full h-full flex justify-center items-center select-none text-center text-stone-400/80 text-sm">
|
||||
No system selected (or toggle “Show all systems”)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNothingSelected && showLoading && (
|
||||
<div className="w-full h-full flex justify-center items-center select-none text-center text-stone-400/80 text-sm">
|
||||
Loading Kills...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNothingSelected && !showLoading && error && (
|
||||
<div className="w-full h-full flex justify-center items-center select-none text-center text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNothingSelected && !showLoading && !error && (!kills || kills.length === 0) && (
|
||||
<div className="w-full h-full flex justify-center items-center select-none text-center text-stone-400/80 text-sm">
|
||||
No kills found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNothingSelected && !showLoading && !error && (
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
<SystemKillsContent
|
||||
key={settings.compact ? 'compact' : 'normal'}
|
||||
kills={kills}
|
||||
systemNameMap={systemNameMap}
|
||||
compact={settings.compact}
|
||||
onlyOneSystem={!visible}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Widget>
|
||||
</div>
|
||||
|
||||
<KillsSettingsDialog visible={settingsDialogVisible} setVisible={setSettingsDialogVisible} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
.TableRowCompact {
|
||||
height: 8px;
|
||||
max-height: 8px;
|
||||
font-size: 12px !important;
|
||||
line-height: 8px;
|
||||
}
|
||||
|
||||
.Table {
|
||||
font-size: 12px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.Tooltip {
|
||||
white-space: pre-line;
|
||||
line-height: 1.2rem;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { DetailedKill } from '@/hooks/Mapper/types/kills';
|
||||
import { KillRow } from '../components/SystemKillsRow';
|
||||
|
||||
interface SystemKillsContentProps {
|
||||
kills: DetailedKill[];
|
||||
systemNameMap: Record<string, string>;
|
||||
compact?: boolean;
|
||||
onlyOneSystem?: boolean;
|
||||
}
|
||||
|
||||
export const SystemKillsContent: React.FC<SystemKillsContentProps> = ({
|
||||
kills,
|
||||
systemNameMap,
|
||||
compact = false,
|
||||
onlyOneSystem = false,
|
||||
}) => {
|
||||
const sortedKills = useMemo(() => {
|
||||
return [...kills].sort((a, b) => {
|
||||
const timeA = a.kill_time ? new Date(a.kill_time).getTime() : 0;
|
||||
const timeB = b.kill_time ? new Date(b.kill_time).getTime() : 0;
|
||||
return timeB - timeA;
|
||||
});
|
||||
}, [kills]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col w-full text-stone-200 text-xs transition-all duration-300',
|
||||
compact ? 'p-1' : 'p-1',
|
||||
)}
|
||||
>
|
||||
{sortedKills.map(kill => {
|
||||
const systemIdStr = String(kill.solar_system_id);
|
||||
const systemName = systemNameMap[systemIdStr] || `System ${systemIdStr}`;
|
||||
|
||||
return (
|
||||
<KillRow
|
||||
key={kill.killmail_id}
|
||||
killDetails={kill}
|
||||
systemName={systemName}
|
||||
isCompact={compact}
|
||||
onlyOneSystem={onlyOneSystem}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { zkillLink } from '../helpers';
|
||||
import classes from './SystemKillRow.module.scss';
|
||||
|
||||
interface AttackerRowSubInfoProps {
|
||||
finalBlowCharId: number | null | undefined;
|
||||
finalBlowCharName?: string;
|
||||
attackerPortraitUrl: string | null;
|
||||
|
||||
finalBlowCorpId: number | null | undefined;
|
||||
finalBlowCorpName?: string;
|
||||
attackerCorpLogoUrl: string | null;
|
||||
|
||||
finalBlowAllianceId: number | null | undefined;
|
||||
finalBlowAllianceName?: string;
|
||||
attackerAllianceLogoUrl: string | null;
|
||||
|
||||
containerHeight?: number;
|
||||
}
|
||||
|
||||
export const AttackerRowSubInfo: React.FC<AttackerRowSubInfoProps> = ({
|
||||
finalBlowCharId = 0,
|
||||
finalBlowCharName,
|
||||
attackerPortraitUrl,
|
||||
containerHeight = 8,
|
||||
}) => {
|
||||
if (!attackerPortraitUrl || finalBlowCharId === null || finalBlowCharId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const containerClass = `h-${containerHeight}`;
|
||||
|
||||
return (
|
||||
<div className={clsx('flex items-start gap-1', containerClass)}>
|
||||
<div className="relative shrink-0 w-auto h-full overflow-hidden">
|
||||
<a
|
||||
href={zkillLink('character', finalBlowCharId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block h-full"
|
||||
>
|
||||
<img
|
||||
src={attackerPortraitUrl}
|
||||
alt={finalBlowCharName || 'AttackerPortrait'}
|
||||
className={clsx(classes.killRowImage, 'h-full w-auto object-contain')}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { DetailedKill } from '@/hooks/Mapper/types/kills';
|
||||
import {
|
||||
formatISK,
|
||||
formatTimeMixed,
|
||||
zkillLink,
|
||||
getAttackerSubscript,
|
||||
buildVictimImageUrls,
|
||||
buildAttackerImageUrls,
|
||||
getPrimaryLogoAndTooltip,
|
||||
getAttackerPrimaryImageAndTooltip,
|
||||
} from '../helpers';
|
||||
import { WdTooltipWrapper } from '../../../../ui-kit/WdTooltipWrapper';
|
||||
import classes from './SystemKillRow.module.scss';
|
||||
import { TooltipPosition } from '@/hooks/Mapper/components/ui-kit';
|
||||
|
||||
export interface CompactKillRowProps {
|
||||
killDetails: DetailedKill;
|
||||
systemName: string;
|
||||
onlyOneSystem: boolean;
|
||||
}
|
||||
|
||||
export const CompactKillRow: React.FC<CompactKillRowProps> = ({ killDetails, systemName, onlyOneSystem }) => {
|
||||
const {
|
||||
killmail_id = 0,
|
||||
|
||||
victim_char_name = 'Unknown Pilot',
|
||||
victim_alliance_ticker = '',
|
||||
victim_corp_ticker = '',
|
||||
victim_ship_name = 'Unknown Ship',
|
||||
victim_corp_name = '',
|
||||
victim_alliance_name = '',
|
||||
victim_char_id = 0,
|
||||
victim_corp_id = 0,
|
||||
victim_alliance_id = 0,
|
||||
victim_ship_type_id = 0,
|
||||
|
||||
final_blow_char_id = 0,
|
||||
final_blow_char_name = '',
|
||||
final_blow_alliance_ticker = '',
|
||||
final_blow_alliance_name = '',
|
||||
final_blow_alliance_id = 0,
|
||||
final_blow_corp_ticker = '',
|
||||
final_blow_corp_id = 0,
|
||||
final_blow_corp_name = '',
|
||||
final_blow_ship_type_id = 0,
|
||||
|
||||
kill_time = '',
|
||||
total_value = 0,
|
||||
} = killDetails || {};
|
||||
|
||||
const attackerIsNpc = final_blow_char_id === 0;
|
||||
const victimAffiliationTicker = victim_alliance_ticker || victim_corp_ticker || 'No Ticker';
|
||||
const killValueFormatted = total_value != null && total_value > 0 ? `${formatISK(total_value)} ISK` : null;
|
||||
const attackerName = attackerIsNpc ? '' : final_blow_char_name;
|
||||
const attackerTicker = attackerIsNpc ? '' : final_blow_alliance_ticker || final_blow_corp_ticker || '';
|
||||
const killTimeAgo = kill_time ? formatTimeMixed(kill_time) : '0h ago';
|
||||
const attackerSubscript = getAttackerSubscript(killDetails);
|
||||
|
||||
const { victimCorpLogoUrl, victimAllianceLogoUrl } = buildVictimImageUrls({
|
||||
victim_char_id,
|
||||
victim_ship_type_id,
|
||||
victim_corp_id,
|
||||
victim_alliance_id,
|
||||
});
|
||||
const { attackerCorpLogoUrl, attackerAllianceLogoUrl } = buildAttackerImageUrls({
|
||||
final_blow_char_id,
|
||||
final_blow_corp_id,
|
||||
final_blow_alliance_id,
|
||||
});
|
||||
|
||||
const { url: victimPrimaryLogoUrl, tooltip: victimPrimaryTooltip } = getPrimaryLogoAndTooltip(
|
||||
victimAllianceLogoUrl,
|
||||
victimCorpLogoUrl,
|
||||
victim_alliance_name,
|
||||
victim_corp_name,
|
||||
'Victim',
|
||||
);
|
||||
|
||||
const { url: attackerPrimaryImageUrl, tooltip: attackerPrimaryTooltip } = getAttackerPrimaryImageAndTooltip(
|
||||
attackerIsNpc,
|
||||
attackerAllianceLogoUrl,
|
||||
attackerCorpLogoUrl,
|
||||
final_blow_alliance_name,
|
||||
final_blow_corp_name,
|
||||
final_blow_ship_type_id || 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'h-10 flex items-center border-b border-stone-800',
|
||||
'text-xs whitespace-nowrap overflow-hidden leading-none',
|
||||
)}
|
||||
>
|
||||
{victimPrimaryLogoUrl && (
|
||||
<WdTooltipWrapper content={victimPrimaryTooltip} position={TooltipPosition.top}>
|
||||
<a
|
||||
href={zkillLink('kill', killmail_id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative shrink-0 w-8 h-8 overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={victimPrimaryLogoUrl}
|
||||
alt="VictimPrimaryLogo"
|
||||
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
|
||||
/>
|
||||
</a>
|
||||
</WdTooltipWrapper>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col ml-2 min-w-0 overflow-hidden leading-[1rem]">
|
||||
<div className="truncate text-stone-200">
|
||||
{victim_char_name}
|
||||
<span className="text-stone-400"> / {victimAffiliationTicker}</span>
|
||||
</div>
|
||||
<div className="truncate text-stone-300">
|
||||
{victim_ship_name}
|
||||
{killValueFormatted && (
|
||||
<>
|
||||
<span className="ml-1 text-stone-400">/</span>
|
||||
<span className="ml-1 text-green-400">{killValueFormatted}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center ml-auto gap-2">
|
||||
<div className="flex flex-col items-end min-w-0 overflow-hidden text-right leading-[1rem]">
|
||||
{!attackerIsNpc && (attackerName || attackerTicker) && (
|
||||
<div className="truncate text-stone-200">
|
||||
{attackerName}
|
||||
{attackerTicker && <span className="ml-1 text-stone-400">/ {attackerTicker}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="truncate text-stone-400">
|
||||
{!onlyOneSystem && systemName ? (
|
||||
<>
|
||||
{systemName} / <span className="ml-1 text-red-400">{killTimeAgo}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-red-400">{killTimeAgo}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{attackerPrimaryImageUrl && (
|
||||
<WdTooltipWrapper content={attackerPrimaryTooltip} position={TooltipPosition.top}>
|
||||
<a
|
||||
href={zkillLink('kill', killmail_id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative shrink-0 w-8 h-8 overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={attackerPrimaryImageUrl}
|
||||
alt={attackerIsNpc ? 'NpcShip' : 'AttackerPrimaryLogo'}
|
||||
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
|
||||
/>
|
||||
{attackerSubscript && (
|
||||
<span
|
||||
className={clsx(
|
||||
classes.attackerCountLabel,
|
||||
attackerSubscript.cssClass,
|
||||
'text-[0.6rem] leading-none px-[2px]',
|
||||
)}
|
||||
style={{ bottom: 0, right: 0 }}
|
||||
>
|
||||
{attackerSubscript.label}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</WdTooltipWrapper>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
// FullKillRow.tsx
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { DetailedKill } from '@/hooks/Mapper/types/kills';
|
||||
import {
|
||||
formatISK,
|
||||
formatTimeMixed,
|
||||
zkillLink,
|
||||
getAttackerSubscript,
|
||||
buildVictimImageUrls,
|
||||
buildAttackerImageUrls,
|
||||
getPrimaryLogoAndTooltip,
|
||||
getAttackerPrimaryImageAndTooltip,
|
||||
} from '../helpers';
|
||||
import { VictimRowSubInfo } from './VictimRowSubInfo';
|
||||
import { WdTooltipWrapper } from '../../../../ui-kit/WdTooltipWrapper';
|
||||
import classes from './SystemKillRow.module.scss';
|
||||
import { TooltipPosition } from '@/hooks/Mapper/components/ui-kit';
|
||||
|
||||
export interface FullKillRowProps {
|
||||
killDetails: DetailedKill;
|
||||
systemName: string;
|
||||
onlyOneSystem: boolean;
|
||||
}
|
||||
|
||||
export const FullKillRow: React.FC<FullKillRowProps> = ({ killDetails, systemName, onlyOneSystem }) => {
|
||||
const {
|
||||
killmail_id = 0,
|
||||
|
||||
victim_char_name = '',
|
||||
victim_alliance_ticker = '',
|
||||
victim_corp_ticker = '',
|
||||
victim_ship_name = '',
|
||||
victim_char_id = 0,
|
||||
victim_corp_id = 0,
|
||||
victim_alliance_id = 0,
|
||||
victim_ship_type_id = 0,
|
||||
victim_corp_name = '',
|
||||
victim_alliance_name = '',
|
||||
|
||||
final_blow_char_id = 0,
|
||||
final_blow_char_name = '',
|
||||
final_blow_alliance_ticker = '',
|
||||
final_blow_corp_ticker = '',
|
||||
final_blow_corp_name = '',
|
||||
final_blow_alliance_name = '',
|
||||
final_blow_corp_id = 0,
|
||||
final_blow_alliance_id = 0,
|
||||
final_blow_ship_name = '',
|
||||
final_blow_ship_type_id = 0,
|
||||
|
||||
total_value = 0,
|
||||
kill_time = '',
|
||||
} = killDetails || {};
|
||||
|
||||
const attackerIsNpc = final_blow_char_id === 0;
|
||||
const victimAffiliation = victim_alliance_ticker || victim_corp_ticker;
|
||||
const attackerAffiliation = attackerIsNpc ? '' : final_blow_alliance_ticker || final_blow_corp_ticker || '';
|
||||
|
||||
const killValueFormatted = total_value !== null && total_value > 0 ? `${formatISK(total_value)} ISK` : null;
|
||||
const killTimeAgo = kill_time ? formatTimeMixed(kill_time) : '0h ago';
|
||||
|
||||
const { victimPortraitUrl, victimCorpLogoUrl, victimAllianceLogoUrl } = buildVictimImageUrls({
|
||||
victim_char_id,
|
||||
victim_ship_type_id,
|
||||
victim_corp_id,
|
||||
victim_alliance_id,
|
||||
});
|
||||
const { attackerPortraitUrl, attackerCorpLogoUrl, attackerAllianceLogoUrl } = buildAttackerImageUrls({
|
||||
final_blow_char_id,
|
||||
final_blow_corp_id,
|
||||
final_blow_alliance_id,
|
||||
});
|
||||
|
||||
const { url: victimPrimaryImageUrl, tooltip: victimPrimaryTooltip } = getPrimaryLogoAndTooltip(
|
||||
victimAllianceLogoUrl,
|
||||
victimCorpLogoUrl,
|
||||
victim_alliance_name,
|
||||
victim_corp_name,
|
||||
'Victim',
|
||||
);
|
||||
|
||||
const { url: attackerPrimaryImageUrl, tooltip: attackerPrimaryTooltip } = getAttackerPrimaryImageAndTooltip(
|
||||
attackerIsNpc,
|
||||
attackerAllianceLogoUrl,
|
||||
attackerCorpLogoUrl,
|
||||
final_blow_alliance_name,
|
||||
final_blow_corp_name,
|
||||
final_blow_ship_type_id || 0,
|
||||
);
|
||||
|
||||
const attackerSubscript = getAttackerSubscript(killDetails);
|
||||
|
||||
return (
|
||||
<div className={clsx(classes.killRowContainer, 'h-18 w-full justify-between items-start text-sm py-[4px]')}>
|
||||
{/* ---------------- Victim Side ---------------- */}
|
||||
<div className="flex items-start gap-1 min-w-0 h-full">
|
||||
{/* Victim top-level logo (corp or alliance), with tooltip */}
|
||||
{victimPrimaryImageUrl && (
|
||||
<WdTooltipWrapper content={victimPrimaryTooltip} position={TooltipPosition.top}>
|
||||
<div className="relative shrink-0 w-14 h-14 overflow-hidden">
|
||||
<a
|
||||
href={zkillLink('kill', killmail_id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={victimPrimaryImageUrl}
|
||||
alt="VictimPrimaryLogo"
|
||||
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</WdTooltipWrapper>
|
||||
)}
|
||||
<VictimRowSubInfo
|
||||
victimCharName={victim_char_name}
|
||||
victimCharacterId={victim_char_id}
|
||||
victimPortraitUrl={victimPortraitUrl}
|
||||
/>
|
||||
<div className="flex flex-col text-stone-200 leading-4 min-w-0 overflow-hidden">
|
||||
<div className="truncate">
|
||||
<span className="font-semibold">{victim_char_name}</span>
|
||||
{victimAffiliation && <span className="ml-1 text-stone-400">/ {victimAffiliation}</span>}
|
||||
</div>
|
||||
<div className="truncate text-stone-300">
|
||||
{victim_ship_name}
|
||||
{killValueFormatted && (
|
||||
<>
|
||||
<span className="ml-1 text-stone-400">/</span>
|
||||
<span className="ml-1 text-green-400">{killValueFormatted}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-stone-400">{!onlyOneSystem && systemName && <span>{systemName}</span>}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-1 min-w-0 h-full">
|
||||
<div className="flex flex-col items-end leading-4 min-w-0 overflow-hidden text-right">
|
||||
{!attackerIsNpc && (
|
||||
<div className="truncate font-semibold">
|
||||
{final_blow_char_name}
|
||||
{attackerAffiliation && <span className="ml-1 text-stone-400">/ {attackerAffiliation}</span>}
|
||||
</div>
|
||||
)}
|
||||
{!attackerIsNpc && final_blow_ship_name && (
|
||||
<div className="truncate text-stone-300">{final_blow_ship_name}</div>
|
||||
)}
|
||||
<div className="truncate text-red-400">{killTimeAgo}</div>
|
||||
</div>
|
||||
|
||||
{!attackerIsNpc && attackerPortraitUrl && final_blow_char_id !== null && final_blow_char_id > 0 && (
|
||||
<div className="relative shrink-0 w-14 h-14 overflow-hidden">
|
||||
<a
|
||||
href={zkillLink('character', final_blow_char_id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={attackerPortraitUrl}
|
||||
alt="AttackerPortrait"
|
||||
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attackerPrimaryImageUrl && (
|
||||
<WdTooltipWrapper content={attackerPrimaryTooltip} position={TooltipPosition.top}>
|
||||
<div className="relative shrink-0 w-14 h-14 overflow-hidden">
|
||||
<a
|
||||
href={zkillLink('kill', killmail_id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={attackerPrimaryImageUrl}
|
||||
alt={attackerIsNpc ? 'NpcShip' : 'AttackerPrimaryLogo'}
|
||||
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
|
||||
/>
|
||||
{attackerSubscript && (
|
||||
<span className={clsx(attackerSubscript.cssClass, classes.attackerCountLabel)}>
|
||||
{attackerSubscript.label}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</WdTooltipWrapper>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
.killRowContainer {
|
||||
@apply flex items-center whitespace-nowrap overflow-hidden;
|
||||
&:not(:last-child) {
|
||||
@apply border-b border-stone-800;
|
||||
}
|
||||
@apply bg-transparent transition-all hover:bg-stone-900 hover:border-stone-700;
|
||||
}
|
||||
|
||||
.killRowImage {
|
||||
@apply border border-stone-800 rounded-[4px] object-contain;
|
||||
}
|
||||
|
||||
.attackerCountLabel {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
font-size: 10px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.attackerCountLabelCompact {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
font-size: 0.6rem;
|
||||
line-height: 1;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
padding: 1px 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
LayoutEventBlocker,
|
||||
WdCheckbox,
|
||||
WdImgButton,
|
||||
TooltipPosition,
|
||||
SystemView,
|
||||
} from '@/hooks/Mapper/components/ui-kit';
|
||||
import { useKillsWidgetSettings } from '../hooks/useKillsWidgetSettings';
|
||||
import { PrimeIcons } from 'primereact/api';
|
||||
|
||||
interface KillsWidgetHeaderProps {
|
||||
systemId?: string;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
export const KillsHeader: React.FC<KillsWidgetHeaderProps> = ({ systemId, onOpenSettings }) => {
|
||||
const [settings, setSettings] = useKillsWidgetSettings();
|
||||
const { showAll } = settings;
|
||||
|
||||
const onToggleShowAllVisible = () => {
|
||||
setSettings(prev => ({ ...prev, showAll: !prev.showAll }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center text-xs w-full">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="text-stone-400">
|
||||
Kills
|
||||
{systemId && !showAll && ' in '}
|
||||
</div>
|
||||
{systemId && !showAll && <SystemView systemId={systemId} className="select-none text-center" hideRegion />}
|
||||
</div>
|
||||
|
||||
<LayoutEventBlocker className="flex gap-2 items-center">
|
||||
<WdCheckbox
|
||||
size="xs"
|
||||
labelSide="left"
|
||||
label="Show all systems"
|
||||
value={showAll}
|
||||
classNameLabel="text-stone-400 hover:text-stone-200 transition duration-300"
|
||||
onChange={onToggleShowAllVisible}
|
||||
/>
|
||||
|
||||
<WdImgButton
|
||||
className={PrimeIcons.SLIDERS_H}
|
||||
onClick={onOpenSettings}
|
||||
tooltip={{
|
||||
content: 'Open Kills Settings',
|
||||
position: TooltipPosition.left,
|
||||
}}
|
||||
/>
|
||||
</LayoutEventBlocker>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { DetailedKill } from '@/hooks/Mapper/types/kills';
|
||||
import { CompactKillRow } from './CompactKillRow';
|
||||
import { FullKillRow } from './FullKillRow';
|
||||
|
||||
export interface KillRowProps {
|
||||
killDetails: DetailedKill;
|
||||
systemName: string;
|
||||
isCompact?: boolean;
|
||||
onlyOneSystem?: boolean;
|
||||
}
|
||||
|
||||
export const KillRow: React.FC<KillRowProps> = ({
|
||||
killDetails,
|
||||
systemName,
|
||||
isCompact = false,
|
||||
onlyOneSystem = false,
|
||||
}) => {
|
||||
if (isCompact) {
|
||||
return <CompactKillRow killDetails={killDetails} systemName={systemName} onlyOneSystem={onlyOneSystem} />;
|
||||
}
|
||||
|
||||
return <FullKillRow killDetails={killDetails} systemName={systemName} onlyOneSystem={onlyOneSystem} />;
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Button } from 'primereact/button';
|
||||
import { WdImgButton, SystemView, TooltipPosition } from '@/hooks/Mapper/components/ui-kit';
|
||||
import { PrimeIcons } from 'primereact/api';
|
||||
import { useKillsWidgetSettings } from '../hooks/useKillsWidgetSettings';
|
||||
import {
|
||||
AddSystemDialog,
|
||||
SearchOnSubmitCallback,
|
||||
} from '@/hooks/Mapper/components/mapInterface/components/AddSystemDialog';
|
||||
|
||||
interface KillsSettingsDialogProps {
|
||||
visible: boolean;
|
||||
setVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
export const KillsSettingsDialog: React.FC<KillsSettingsDialogProps> = ({ visible, setVisible }) => {
|
||||
const [globalSettings, setGlobalSettings] = useKillsWidgetSettings();
|
||||
const localRef = useRef({
|
||||
compact: globalSettings.compact,
|
||||
showAll: globalSettings.showAll,
|
||||
excludedSystems: globalSettings.excludedSystems || [],
|
||||
});
|
||||
|
||||
const [, forceRender] = useState(0);
|
||||
|
||||
const [addSystemDialogVisible, setAddSystemDialogVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
localRef.current = {
|
||||
compact: globalSettings.compact,
|
||||
showAll: globalSettings.showAll,
|
||||
excludedSystems: globalSettings.excludedSystems || [],
|
||||
};
|
||||
forceRender(n => n + 1);
|
||||
}
|
||||
}, [visible, globalSettings]);
|
||||
|
||||
const handleCompactChange = useCallback((checked: boolean) => {
|
||||
localRef.current = {
|
||||
...localRef.current,
|
||||
compact: checked,
|
||||
};
|
||||
forceRender(n => n + 1);
|
||||
}, []);
|
||||
|
||||
const handleRemoveSystem = useCallback((sysId: number) => {
|
||||
localRef.current = {
|
||||
...localRef.current,
|
||||
excludedSystems: localRef.current.excludedSystems.filter(id => id !== sysId),
|
||||
};
|
||||
forceRender(n => n + 1);
|
||||
}, []);
|
||||
|
||||
const handleAddSystemSubmit: SearchOnSubmitCallback = useCallback(item => {
|
||||
if (localRef.current.excludedSystems.includes(item.value)) {
|
||||
return;
|
||||
}
|
||||
localRef.current = {
|
||||
...localRef.current,
|
||||
excludedSystems: [...localRef.current.excludedSystems, item.value],
|
||||
};
|
||||
forceRender(n => n + 1);
|
||||
}, []);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
setGlobalSettings(prev => ({
|
||||
...prev,
|
||||
...localRef.current,
|
||||
}));
|
||||
setVisible(false);
|
||||
}, [setGlobalSettings, setVisible]);
|
||||
|
||||
const handleHide = useCallback(() => {
|
||||
setVisible(false);
|
||||
}, [setVisible]);
|
||||
|
||||
const localData = localRef.current;
|
||||
const excluded = localData.excludedSystems || [];
|
||||
|
||||
return (
|
||||
<Dialog header="Kills Settings" visible={visible} style={{ width: '440px' }} draggable={false} onHide={handleHide}>
|
||||
<div className="flex flex-col gap-3 p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="kills-compact-mode"
|
||||
checked={localData.compact}
|
||||
onChange={e => handleCompactChange(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="kills-compact-mode" className="cursor-pointer">
|
||||
Use compact mode
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-stone-400">Excluded Systems</label>
|
||||
<WdImgButton
|
||||
className={PrimeIcons.PLUS_CIRCLE}
|
||||
onClick={() => setAddSystemDialogVisible(true)}
|
||||
tooltip={{ content: 'Add system to excluded list' }}
|
||||
/>
|
||||
</div>
|
||||
{excluded.length === 0 && <div className="text-stone-500 text-xs italic">No systems excluded.</div>}
|
||||
{excluded.map(sysId => (
|
||||
<div key={sysId} className="flex items-center justify-between border-b border-stone-600 py-1 px-1 text-xs">
|
||||
<SystemView systemId={sysId.toString()} hideRegion compact />
|
||||
|
||||
<WdImgButton
|
||||
className={PrimeIcons.TRASH}
|
||||
onClick={() => handleRemoveSystem(sysId)}
|
||||
tooltip={{ content: 'Remove from excluded', position: TooltipPosition.top }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Apply + Close button row */}
|
||||
<div className="flex gap-2 justify-end mt-4">
|
||||
<Button onClick={handleApply} label="Apply" outlined size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AddSystemDialog for picking new systems to exclude */}
|
||||
<AddSystemDialog
|
||||
title="Add system to kills exclude list"
|
||||
visible={addSystemDialogVisible}
|
||||
setVisible={() => setAddSystemDialogVisible(false)}
|
||||
onSubmit={handleAddSystemSubmit}
|
||||
excludedSystems={excluded}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
// VictimSubRowInfo.tsx
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { zkillLink } from '../helpers';
|
||||
import classes from './SystemKillRow.module.scss';
|
||||
|
||||
interface VictimRowSubInfoProps {
|
||||
victimCharacterId: number | null;
|
||||
victimPortraitUrl: string | null;
|
||||
victimCharName?: string;
|
||||
}
|
||||
|
||||
export const VictimRowSubInfo: React.FC<VictimRowSubInfoProps> = ({
|
||||
victimCharacterId = 0,
|
||||
victimPortraitUrl,
|
||||
victimCharName,
|
||||
}) => {
|
||||
if (!victimPortraitUrl || victimCharacterId === null || victimCharacterId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-1 h-14">
|
||||
<div className="relative shrink-0 w-14 h-14 overflow-hidden">
|
||||
<a
|
||||
href={zkillLink('character', victimCharacterId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={victimPortraitUrl}
|
||||
alt={victimCharName || 'Victim Portrait'}
|
||||
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './linkHelpers';
|
||||
export * from './killRowUtils';
|
||||
@@ -0,0 +1,47 @@
|
||||
import { DetailedKill } from '@/hooks/Mapper/types/kills';
|
||||
|
||||
/** Returns "5m ago", "3h ago", "2.5d ago", etc. */
|
||||
export function formatTimeMixed(killTime: string): string {
|
||||
const killDate = new Date(killTime);
|
||||
const diffMs = Date.now() - killDate.getTime();
|
||||
const diffHours = diffMs / (1000 * 60 * 60);
|
||||
|
||||
if (diffHours < 1) {
|
||||
const mins = Math.round(diffHours * 60);
|
||||
return `${mins}m ago`;
|
||||
} else if (diffHours < 24) {
|
||||
const hours = Math.round(diffHours);
|
||||
return `${hours}h ago`;
|
||||
} else {
|
||||
const days = diffHours / 24;
|
||||
const roundedDays = days.toFixed(1);
|
||||
return `${roundedDays}d ago`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats integer ISK values into k/M/B/T. */
|
||||
export function formatISK(value: number): string {
|
||||
if (value >= 1_000_000_000_000) {
|
||||
return `${(value / 1_000_000_000_000).toFixed(2)}T`;
|
||||
} else if (value >= 1_000_000_000) {
|
||||
return `${(value / 1_000_000_000).toFixed(2)}B`;
|
||||
} else if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(2)}M`;
|
||||
} else if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(2)}k`;
|
||||
}
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
|
||||
export function getAttackerSubscript(kill: DetailedKill) {
|
||||
if (kill.npc) {
|
||||
return { label: 'npc', cssClass: 'text-purple-400' };
|
||||
}
|
||||
const count = kill.attacker_count ?? 0;
|
||||
if (count === 1) {
|
||||
return { label: 'solo', cssClass: 'text-green-400' };
|
||||
} else if (count > 1) {
|
||||
return { label: String(count), cssClass: 'text-white' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
const ZKILL_URL = 'https://zkillboard.com';
|
||||
const BASE_IMAGE_URL = 'https://images.evetech.net';
|
||||
|
||||
export function zkillLink(type: 'kill' | 'character' | 'corporation' | 'alliance', id?: number | null): string {
|
||||
if (!id) return `${ZKILL_URL}`;
|
||||
if (type === 'kill') return `${ZKILL_URL}/kill/${id}/`;
|
||||
if (type === 'character') return `${ZKILL_URL}/character/${id}/`;
|
||||
if (type === 'corporation') return `${ZKILL_URL}/corporation/${id}/`;
|
||||
if (type === 'alliance') return `${ZKILL_URL}/alliance/${id}/`;
|
||||
return `${ZKILL_URL}`;
|
||||
}
|
||||
|
||||
export function eveImageUrl(
|
||||
category: 'characters' | 'corporations' | 'alliances' | 'types',
|
||||
id?: number | null,
|
||||
variation: string = 'icon',
|
||||
size?: number,
|
||||
): string | null {
|
||||
if (!id || id <= 0) {
|
||||
return null;
|
||||
}
|
||||
let url = `${BASE_IMAGE_URL}/${category}/${id}/${variation}`;
|
||||
if (size) {
|
||||
url += `?size=${size}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function buildVictimImageUrls(args: {
|
||||
victim_char_id?: number | null;
|
||||
victim_ship_type_id?: number | null;
|
||||
victim_corp_id?: number | null;
|
||||
victim_alliance_id?: number | null;
|
||||
}) {
|
||||
const { victim_char_id, victim_ship_type_id, victim_corp_id, victim_alliance_id } = args;
|
||||
|
||||
const victimPortraitUrl = eveImageUrl('characters', victim_char_id, 'portrait', 64);
|
||||
const victimShipUrl = eveImageUrl('types', victim_ship_type_id, 'render', 64);
|
||||
const victimCorpLogoUrl = eveImageUrl('corporations', victim_corp_id, 'logo', 32);
|
||||
const victimAllianceLogoUrl = eveImageUrl('alliances', victim_alliance_id, 'logo', 32);
|
||||
|
||||
return {
|
||||
victimPortraitUrl,
|
||||
victimShipUrl,
|
||||
victimCorpLogoUrl,
|
||||
victimAllianceLogoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAttackerShipUrl(final_blow_ship_type_id?: number | null): string | null {
|
||||
return eveImageUrl('types', final_blow_ship_type_id, 'render', 64);
|
||||
}
|
||||
|
||||
export function buildAttackerImageUrls(args: {
|
||||
final_blow_char_id?: number | null;
|
||||
final_blow_corp_id?: number | null;
|
||||
final_blow_alliance_id?: number | null;
|
||||
}) {
|
||||
const { final_blow_char_id, final_blow_corp_id, final_blow_alliance_id } = args;
|
||||
|
||||
const attackerPortraitUrl = eveImageUrl('characters', final_blow_char_id, 'portrait', 64);
|
||||
const attackerCorpLogoUrl = eveImageUrl('corporations', final_blow_corp_id, 'logo', 32);
|
||||
const attackerAllianceLogoUrl = eveImageUrl('alliances', final_blow_alliance_id, 'logo', 32);
|
||||
|
||||
return {
|
||||
attackerPortraitUrl,
|
||||
attackerCorpLogoUrl,
|
||||
attackerAllianceLogoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPrimaryLogoAndTooltip(
|
||||
allianceUrl: string | null,
|
||||
corpUrl: string | null,
|
||||
allianceName: string,
|
||||
corpName: string,
|
||||
fallback: string,
|
||||
) {
|
||||
let url: string | null = null;
|
||||
let tooltip = '';
|
||||
|
||||
if (allianceUrl) {
|
||||
url = allianceUrl;
|
||||
tooltip = allianceName || fallback;
|
||||
} else if (corpUrl) {
|
||||
url = corpUrl;
|
||||
tooltip = corpName || fallback;
|
||||
}
|
||||
|
||||
return { url, tooltip };
|
||||
}
|
||||
|
||||
export function getAttackerPrimaryImageAndTooltip(
|
||||
isNpc: boolean,
|
||||
allianceUrl: string | null,
|
||||
corpUrl: string | null,
|
||||
allianceName: string,
|
||||
corpName: string,
|
||||
finalBlowShipTypeId: number,
|
||||
npcFallback: string = 'NPC Attacker',
|
||||
) {
|
||||
if (isNpc) {
|
||||
const shipUrl = buildAttackerShipUrl(finalBlowShipTypeId);
|
||||
return {
|
||||
url: shipUrl,
|
||||
tooltip: npcFallback,
|
||||
};
|
||||
}
|
||||
|
||||
return getPrimaryLogoAndTooltip(allianceUrl, corpUrl, allianceName, corpName, 'Attacker');
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import useLocalStorageState from 'use-local-storage-state';
|
||||
|
||||
export interface KillsWidgetSettings {
|
||||
compact: boolean;
|
||||
showAll: boolean;
|
||||
excludedSystems: number[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_KILLS_WIDGET_SETTINGS: KillsWidgetSettings = {
|
||||
compact: false,
|
||||
showAll: false,
|
||||
excludedSystems: [],
|
||||
version: 0,
|
||||
};
|
||||
|
||||
function mergeWithDefaults(settings?: Partial<KillsWidgetSettings>): KillsWidgetSettings {
|
||||
if (!settings) {
|
||||
return DEFAULT_KILLS_WIDGET_SETTINGS;
|
||||
}
|
||||
|
||||
return {
|
||||
...DEFAULT_KILLS_WIDGET_SETTINGS,
|
||||
...settings,
|
||||
excludedSystems: Array.isArray(settings.excludedSystems) ? settings.excludedSystems : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function useKillsWidgetSettings() {
|
||||
const [rawValue, setRawValue] = useLocalStorageState<KillsWidgetSettings | undefined>('kills:widget:settings');
|
||||
|
||||
const value = useMemo<KillsWidgetSettings>(() => {
|
||||
return mergeWithDefaults(rawValue);
|
||||
}, [rawValue]);
|
||||
|
||||
const setValue = useCallback(
|
||||
(newVal: KillsWidgetSettings | ((prev: KillsWidgetSettings) => KillsWidgetSettings)) => {
|
||||
setRawValue(prev => {
|
||||
const mergedPrev = mergeWithDefaults(prev);
|
||||
|
||||
const nextUnmerged = typeof newVal === 'function' ? newVal(mergedPrev) : newVal;
|
||||
|
||||
return mergeWithDefaults(nextUnmerged);
|
||||
});
|
||||
},
|
||||
[setRawValue],
|
||||
);
|
||||
|
||||
return [value, setValue] as const;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useCallback, useMemo, useState, useEffect, useRef } from 'react';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { OutCommand } from '@/hooks/Mapper/types/mapHandlers';
|
||||
import { DetailedKill } from '@/hooks/Mapper/types/kills';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { useKillsWidgetSettings } from './useKillsWidgetSettings';
|
||||
|
||||
interface UseSystemKillsProps {
|
||||
systemId?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
outCommand: (payload: any) => Promise<any>;
|
||||
showAllVisible?: boolean;
|
||||
sinceHours?: number;
|
||||
}
|
||||
|
||||
function combineKills(existing: DetailedKill[], incoming: DetailedKill[], sinceHours: number): DetailedKill[] {
|
||||
const cutoff = Date.now() - sinceHours * 60 * 60 * 1000;
|
||||
const byId: Record<string, DetailedKill> = {};
|
||||
|
||||
for (const kill of [...existing, ...incoming]) {
|
||||
if (!kill.kill_time) {
|
||||
continue;
|
||||
}
|
||||
const killTimeMs = new Date(kill.kill_time).valueOf();
|
||||
|
||||
if (killTimeMs >= cutoff) {
|
||||
byId[kill.killmail_id] = kill;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(byId);
|
||||
}
|
||||
|
||||
export function useSystemKills({ systemId, outCommand, showAllVisible = false, sinceHours = 24 }: UseSystemKillsProps) {
|
||||
const { data, update } = useMapRootState();
|
||||
const { detailedKills = {}, systems = [] } = data;
|
||||
|
||||
const [settings] = useKillsWidgetSettings();
|
||||
const excludedSystems = settings.excludedSystems;
|
||||
|
||||
const visibleSystemIds = useMemo(() => {
|
||||
return systems.map(s => s.id).filter(id => !excludedSystems.includes(Number(id)));
|
||||
}, [systems, excludedSystems]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const didFallbackFetch = useRef(Object.keys(detailedKills).length !== 0);
|
||||
|
||||
const mergeKillsIntoGlobal = useCallback(
|
||||
(killsMap: Record<string, DetailedKill[]>) => {
|
||||
update(prev => {
|
||||
const oldMap = prev.detailedKills ?? {};
|
||||
const updated: Record<string, DetailedKill[]> = { ...oldMap };
|
||||
|
||||
for (const [sid, newKills] of Object.entries(killsMap)) {
|
||||
const existing = updated[sid] ?? [];
|
||||
const combined = combineKills(existing, newKills, sinceHours);
|
||||
updated[sid] = combined;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
detailedKills: updated,
|
||||
};
|
||||
});
|
||||
},
|
||||
[update, sinceHours],
|
||||
);
|
||||
|
||||
const fetchKills = useCallback(
|
||||
async (forceFallback = false) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let eventType: OutCommand;
|
||||
let requestData: Record<string, unknown>;
|
||||
|
||||
if (showAllVisible || forceFallback) {
|
||||
eventType = OutCommand.getSystemsKills;
|
||||
requestData = {
|
||||
system_ids: visibleSystemIds,
|
||||
since_hours: sinceHours,
|
||||
};
|
||||
} else if (systemId) {
|
||||
eventType = OutCommand.getSystemKills;
|
||||
requestData = {
|
||||
system_id: systemId,
|
||||
since_hours: sinceHours,
|
||||
};
|
||||
} else {
|
||||
// If there's no system and not showing all, do nothing
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await outCommand({
|
||||
type: eventType,
|
||||
data: requestData,
|
||||
});
|
||||
|
||||
// Single system => `resp.kills`
|
||||
if (resp.kills) {
|
||||
const arr = resp.kills as DetailedKill[];
|
||||
const sid = systemId ?? 'unknown';
|
||||
mergeKillsIntoGlobal({ [sid]: arr });
|
||||
}
|
||||
// multiple => `resp.systems_kills`
|
||||
else if (resp.systems_kills) {
|
||||
mergeKillsIntoGlobal(resp.systems_kills as Record<string, DetailedKill[]>);
|
||||
} else {
|
||||
console.warn('[useSystemKills] Unexpected kills response =>', resp);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[useSystemKills] Failed to fetch kills:', err);
|
||||
setError(err instanceof Error ? err.message : 'Error fetching kills');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[showAllVisible, systemId, outCommand, visibleSystemIds, sinceHours, mergeKillsIntoGlobal],
|
||||
);
|
||||
|
||||
const debouncedFetchKills = useMemo(
|
||||
() =>
|
||||
debounce(fetchKills, 500, {
|
||||
leading: true,
|
||||
trailing: false,
|
||||
}),
|
||||
[fetchKills],
|
||||
);
|
||||
|
||||
const finalKills = useMemo(() => {
|
||||
if (showAllVisible) {
|
||||
return visibleSystemIds.flatMap(sid => detailedKills[sid] ?? []);
|
||||
} else if (systemId) {
|
||||
return detailedKills[systemId] ?? [];
|
||||
} else if (didFallbackFetch.current) {
|
||||
// if we already did a fallback, we may have data for multiple systems
|
||||
return visibleSystemIds.flatMap(sid => detailedKills[sid] ?? []);
|
||||
}
|
||||
return [];
|
||||
}, [showAllVisible, systemId, didFallbackFetch, visibleSystemIds, detailedKills]);
|
||||
|
||||
const effectiveIsLoading = isLoading && finalKills.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!systemId && !showAllVisible && !didFallbackFetch.current) {
|
||||
didFallbackFetch.current = true;
|
||||
// Cancel any queued debounced calls, then do the fallback.
|
||||
debouncedFetchKills.cancel();
|
||||
fetchKills(true); // forceFallback => fetch as though showAll
|
||||
}
|
||||
}, [systemId, showAllVisible, debouncedFetchKills, fetchKills, didFallbackFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleSystemIds.length === 0) return;
|
||||
|
||||
if (showAllVisible || systemId) {
|
||||
debouncedFetchKills();
|
||||
// Clean up the debounce on unmount or changes
|
||||
return () => debouncedFetchKills.cancel();
|
||||
}
|
||||
}, [showAllVisible, systemId, visibleSystemIds, debouncedFetchKills]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
debouncedFetchKills.cancel();
|
||||
fetchKills(); // immediate (non-debounced) call
|
||||
}, [debouncedFetchKills, fetchKills]);
|
||||
|
||||
return {
|
||||
kills: finalKills,
|
||||
isLoading: effectiveIsLoading,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './SystemKills';
|
||||
@@ -1,28 +1,29 @@
|
||||
import { PrimeIcons } from 'primereact/api';
|
||||
import { SignatureGroup, SystemSignature } from '@/hooks/Mapper/types';
|
||||
import { SystemViewStandalone, WHClassView } from '@/hooks/Mapper/components/ui-kit';
|
||||
import { SystemViewStandalone, TooltipPosition, WHClassView } from '@/hooks/Mapper/components/ui-kit';
|
||||
|
||||
import {
|
||||
k162Types,
|
||||
renderK162Type,
|
||||
} from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureK162TypeSelect';
|
||||
import { renderK162Type } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureK162TypeSelect';
|
||||
import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { renderName } from './renderName.tsx';
|
||||
import { K162_TYPES_MAP } from '@/hooks/Mapper/constants.ts';
|
||||
import { parseSignatureCustomInfo } from '@/hooks/Mapper/helpers/parseSignatureCustomInfo.ts';
|
||||
|
||||
export const renderInfoColumn = (row: SystemSignature) => {
|
||||
if (!row.group || row.group === SignatureGroup.Wormhole) {
|
||||
let k162TypeOption = null;
|
||||
if (row.custom_info) {
|
||||
const customInfo = JSON.parse(row.custom_info);
|
||||
if (customInfo.k162Type) {
|
||||
k162TypeOption = k162Types.find(x => x.value === customInfo.k162Type);
|
||||
}
|
||||
}
|
||||
const customInfo = parseSignatureCustomInfo(row.custom_info);
|
||||
|
||||
const k162TypeOption = customInfo.k162Type ? K162_TYPES_MAP[customInfo.k162Type] : null;
|
||||
|
||||
return (
|
||||
<div className="flex justify-start items-center gap-[4px]">
|
||||
{customInfo.isEOL && (
|
||||
<WdTooltipWrapper offset={5} position={TooltipPosition.top} content="Signature marked as EOL">
|
||||
<div className="pi pi-clock text-fuchsia-400 text-[11px] mr-[2px]"></div>
|
||||
</WdTooltipWrapper>
|
||||
)}
|
||||
|
||||
{row.type && (
|
||||
<WHClassView
|
||||
className="text-[11px]"
|
||||
@@ -34,7 +35,7 @@ export const renderInfoColumn = (row: SystemSignature) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!row.linked_system && row.type === 'K162' && !!k162TypeOption && <>{renderK162Type(k162TypeOption)}</>}
|
||||
{!row.linked_system && row.type === 'K162' && k162TypeOption && renderK162Type(k162TypeOption)}
|
||||
|
||||
{row.linked_system && (
|
||||
<>
|
||||
|
||||
@@ -70,6 +70,11 @@ export const SystemStructures: React.FC = () => {
|
||||
<WdImgButton
|
||||
className={`${PrimeIcons.CLOCK} text-sky-400 hover:text-sky-200 transition duration-300`}
|
||||
onClick={handlePasteTimer}
|
||||
tooltip={{
|
||||
position: TooltipPosition.left,
|
||||
// @ts-ignore
|
||||
content: 'Add Structures/Timer',
|
||||
}}
|
||||
/>
|
||||
<WdImgButton
|
||||
className={PrimeIcons.QUESTION_CIRCLE}
|
||||
@@ -79,14 +84,14 @@ export const SystemStructures: React.FC = () => {
|
||||
content: (
|
||||
<div className="flex flex-col gap-1">
|
||||
<InfoDrawer title={<b className="text-slate-50">How to add/update structures?</b>}>
|
||||
In game, select one or more structures in D-Scan and press Ctrl+C,
|
||||
In game, select one or more structures in D-Scan and then
|
||||
<br />
|
||||
then click on this widget and press Ctrl+V
|
||||
use the blue add structure data button
|
||||
</InfoDrawer>
|
||||
<InfoDrawer title={<b className="text-slate-50">How to add a timer?</b>}>
|
||||
In game, select a structure with an active timer, right click to copy, and then use the
|
||||
In game, select a structure with an active timer, right click to copy, and then
|
||||
<span className="text-blue-500"> blue </span>
|
||||
add timer button
|
||||
use the blue add structure data button
|
||||
</InfoDrawer>
|
||||
</div>
|
||||
),
|
||||
@@ -101,7 +106,7 @@ export const SystemStructures: React.FC = () => {
|
||||
<div tabIndex={0} onPaste={handlePaste} className="h-full flex flex-col" style={{ outline: 'none' }}>
|
||||
<Widget label={renderWidgetLabel()}>
|
||||
{isNotSelectedSystem ? (
|
||||
<div className="flex-1 flex justify-center items-center select-none text-center text-stone-400/80 text-sm">
|
||||
<div className="w-full h-full flex justify-center items-center select-none text-center text-stone-400/80 text-sm">
|
||||
System is not selected
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
.TableRowCompact {
|
||||
height: 8px;
|
||||
max-height: 8px;
|
||||
font-size: 12px !important;
|
||||
line-height: 8px;
|
||||
}
|
||||
|
||||
.Table {
|
||||
font-size: 12px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.TableRowCompact {
|
||||
height: 8px;
|
||||
max-height: 8px;
|
||||
font-size: 12px !important;
|
||||
line-height: 8px;
|
||||
}
|
||||
|
||||
.Tooltip {
|
||||
white-space: pre-line; // or pre-wrap
|
||||
line-height: 1.2rem;
|
||||
}
|
||||
|
||||
.Table {
|
||||
font-size: 12px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.Table .p-datatable-tbody > tr > td {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.Tooltip {
|
||||
white-space: pre-line;
|
||||
line-height: 1.2rem;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export const SystemStructuresContent: React.FC<SystemStructuresContentProps> = (
|
||||
size="small"
|
||||
sortMode="single"
|
||||
rowHover
|
||||
style={{ tableLayout: 'fixed', width: '100%' }}
|
||||
onRowClick={handleRowClick}
|
||||
onRowDoubleClick={handleRowDoubleClick}
|
||||
rowClassName={rowData => {
|
||||
@@ -74,11 +75,56 @@ export const SystemStructuresContent: React.FC<SystemStructuresContentProps> = (
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Column header="Type" body={renderTypeCell} style={{ width: '160px' }} />
|
||||
<Column field="name" header="Name" style={{ width: '120px' }} />
|
||||
<Column header="Owner" body={renderOwnerCell} style={{ width: '120px' }} />
|
||||
<Column field="status" header="Status" style={{ width: '100px' }} />
|
||||
<Column header="Timer" body={renderTimerCell} style={{ width: '110px' }} />
|
||||
<Column
|
||||
header="Type"
|
||||
body={renderTypeCell}
|
||||
style={{
|
||||
width: '160px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
/>
|
||||
<Column
|
||||
field="name"
|
||||
header="Name"
|
||||
style={{
|
||||
width: '120px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
/>
|
||||
<Column
|
||||
header="Owner"
|
||||
body={renderOwnerCell}
|
||||
style={{
|
||||
width: '120px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
/>
|
||||
<Column
|
||||
field="status"
|
||||
header="Status"
|
||||
style={{
|
||||
width: '100px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
/>
|
||||
<Column
|
||||
header="Timer"
|
||||
body={renderTimerCell}
|
||||
style={{
|
||||
width: '110px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
/>
|
||||
<Column
|
||||
body={(rowData: StructureItem) => (
|
||||
<i
|
||||
@@ -90,7 +136,13 @@ export const SystemStructuresContent: React.FC<SystemStructuresContentProps> = (
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
style={{ width: '40px', textAlign: 'center' }}
|
||||
style={{
|
||||
width: '40px',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
/>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Button } from 'primereact/button';
|
||||
import { AutoComplete } from 'primereact/autocomplete';
|
||||
import { Calendar } from 'primereact/calendar';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { StructureItem, StructureStatus, statusesRequiringTimer, formatToISO } from '../helpers';
|
||||
@@ -53,7 +54,9 @@ export const SystemStructuresDialog: React.FC<StructuresEditDialogProps> = ({
|
||||
|
||||
// If user typed more text but we have partial match in prevResults
|
||||
if (newQuery.startsWith(prevQuery) && prevResults.length > 0) {
|
||||
const filtered = prevResults.filter(item => item.label.toLowerCase().includes(newQuery.toLowerCase()));
|
||||
const filtered = prevResults.filter(item =>
|
||||
item.label.toLowerCase().includes(newQuery.toLowerCase()),
|
||||
);
|
||||
setOwnerSuggestions(filtered);
|
||||
return;
|
||||
}
|
||||
@@ -74,12 +77,18 @@ export const SystemStructuresDialog: React.FC<StructuresEditDialogProps> = ({
|
||||
[prevQuery, prevResults, outCommand],
|
||||
);
|
||||
|
||||
const handleChange = (field: keyof StructureItem, val: string) => {
|
||||
const handleChange = (field: keyof StructureItem, val: string | Date) => {
|
||||
// If we want to forbid changing structureTypeId or structureType from the dialog, do so here:
|
||||
if (field === 'structureTypeId' || field === 'structureType') return;
|
||||
|
||||
setEditData(prev => {
|
||||
if (!prev) return null;
|
||||
|
||||
// If this is the endTime (Date from Calendar), we store as ISO or string:
|
||||
if (field === 'endTime' && val instanceof Date) {
|
||||
return { ...prev, endTime: val.toISOString() };
|
||||
}
|
||||
|
||||
return { ...prev, [field]: val };
|
||||
});
|
||||
};
|
||||
@@ -87,7 +96,9 @@ export const SystemStructuresDialog: React.FC<StructuresEditDialogProps> = ({
|
||||
// when user picks a corp from auto-complete
|
||||
const handleSelectOwner = (selected: { label: string; value: string }) => {
|
||||
setOwnerInput(selected.label);
|
||||
setEditData(prev => (prev ? { ...prev, ownerName: selected.label, ownerId: selected.value } : null));
|
||||
setEditData(prev =>
|
||||
prev ? { ...prev, ownerName: selected.label, ownerId: selected.value } : null,
|
||||
);
|
||||
};
|
||||
|
||||
const handleStatusChange = (val: string) => {
|
||||
@@ -107,7 +118,7 @@ export const SystemStructuresDialog: React.FC<StructuresEditDialogProps> = ({
|
||||
if (!statusesRequiringTimer.includes(editData.status)) {
|
||||
editData.endTime = '';
|
||||
} else if (editData.endTime) {
|
||||
// convert to full ISO
|
||||
// convert to full ISO if not already
|
||||
editData.endTime = formatToISO(editData.endTime);
|
||||
}
|
||||
|
||||
@@ -146,7 +157,11 @@ export const SystemStructuresDialog: React.FC<StructuresEditDialogProps> = ({
|
||||
<div className="flex flex-col gap-2 text-[14px]">
|
||||
<label className="grid grid-cols-[100px_250px_1fr] gap-2 items-center">
|
||||
<span>Type:</span>
|
||||
<input readOnly className="p-inputtext p-component cursor-not-allowed" value={editData.structureType ?? ''} />
|
||||
<input
|
||||
readOnly
|
||||
className="p-inputtext p-component cursor-not-allowed"
|
||||
value={editData.structureType ?? ''}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid grid-cols-[100px_250px_1fr] gap-2 items-center">
|
||||
<span>Name:</span>
|
||||
@@ -186,17 +201,21 @@ export const SystemStructuresDialog: React.FC<StructuresEditDialogProps> = ({
|
||||
<option value="Reinforced">Reinforced</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{statusesRequiringTimer.includes(editData.status) && (
|
||||
<label className="grid grid-cols-[100px_250px_1fr] gap-2 items-center">
|
||||
<span>End Time:</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="p-inputtext p-component"
|
||||
value={editData.endTime ? editData.endTime.replace('Z', '').slice(0, 16) : ''}
|
||||
onChange={e => handleChange('endTime', e.target.value)}
|
||||
<span>Timer <br /> (Eve Time):</span>
|
||||
<Calendar
|
||||
value={editData.endTime ? new Date(editData.endTime) : undefined}
|
||||
onChange={(e) => handleChange('endTime', e.value ?? '')}
|
||||
showTime
|
||||
hourFormat="24"
|
||||
dateFormat="yy-mm-dd"
|
||||
showIcon
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="grid grid-cols-[100px_1fr] gap-2 items-start mt-2">
|
||||
<span className="mt-1">Notes:</span>
|
||||
<textarea
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './SystemInfo';
|
||||
export * from './RoutesWidget';
|
||||
export * from './SystemSignatures';
|
||||
export * from './SystemStructures';
|
||||
export * from './SystemKills';
|
||||
|
||||
@@ -2,7 +2,9 @@ import clsx from 'clsx';
|
||||
import classes from './PassageCard.module.scss';
|
||||
import { Passage } from '@/hooks/Mapper/types';
|
||||
import { TimeAgo } from '@/hooks/Mapper/components/ui-kit';
|
||||
import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper';
|
||||
import { kgToTons } from '@/hooks/Mapper/utils/kgToTons.ts';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type PassageCardType = {
|
||||
// compact?: boolean;
|
||||
@@ -26,6 +28,11 @@ export const getShipName = (name: string) => {
|
||||
export const PassageCard = ({ inserted_at, character: char, ship }: PassageCardType) => {
|
||||
const isOwn = false;
|
||||
|
||||
const insertedAt = useMemo(() => {
|
||||
const date = new Date(inserted_at);
|
||||
return date.toLocaleString();
|
||||
}, [inserted_at]);
|
||||
|
||||
return (
|
||||
<div className={clsx(classes.CharacterCard, 'w-full text-xs', 'flex flex-col box-border')}>
|
||||
<div className="flex flex-col justify-between px-2 py-1 gap-1">
|
||||
@@ -76,7 +83,9 @@ export const PassageCard = ({ inserted_at, character: char, ship }: PassageCardT
|
||||
{/*time and class*/}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-stone-400">
|
||||
<TimeAgo timestamp={inserted_at} />
|
||||
<WdTooltipWrapper content={insertedAt}>
|
||||
<TimeAgo timestamp={inserted_at} />
|
||||
</WdTooltipWrapper>
|
||||
</span>
|
||||
|
||||
<div className="text-stone-400">{kgToTons(parseInt(ship.ship_type_info.mass))}</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import styles from './MapSettings.module.scss';
|
||||
|
||||
import { WdCheckbox } from '@/hooks/Mapper/components/ui-kit';
|
||||
|
||||
interface PrettySwitchboxProps {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PrettySwitchbox } from '@/hooks/Mapper/components/mapRootContent/components/MapSettings/components';
|
||||
import { WIDGETS_CHECKBOXES_PROPS, WidgetsIds } from '@/hooks/Mapper/components/mapInterface/constants.tsx';
|
||||
import { getWidgetsCheckboxesProps, WidgetsIds } from '@/hooks/Mapper/components/mapInterface/constants.tsx';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
@@ -9,17 +9,20 @@ export interface WidgetsSettingsProps {}
|
||||
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
export const WidgetsSettings = ({}: WidgetsSettingsProps) => {
|
||||
const { windowsSettings, toggleWidgetVisibility, resetWidgets } = useMapRootState();
|
||||
const { windowsSettings, toggleWidgetVisibility, resetWidgets, data } = useMapRootState();
|
||||
|
||||
const handleWidgetSettingsChange = useCallback(
|
||||
(widget: WidgetsIds) => toggleWidgetVisibility(widget),
|
||||
[toggleWidgetVisibility],
|
||||
);
|
||||
|
||||
const detailedKillsDisabled = data.options?.detailedKillsDisabled === true;
|
||||
const widgetProps = getWidgetsCheckboxesProps(detailedKillsDisabled);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-2">
|
||||
<div>
|
||||
{WIDGETS_CHECKBOXES_PROPS.map(widget => (
|
||||
{widgetProps.map(widget => (
|
||||
<PrettySwitchbox
|
||||
key={widget.id}
|
||||
label={widget.label}
|
||||
|
||||
@@ -53,6 +53,7 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map
|
||||
...out,
|
||||
custom_info: JSON.stringify({
|
||||
k162Type: values.k162Type,
|
||||
isEOL: values.isEOL,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -127,14 +128,17 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map
|
||||
const { linked_system, custom_info, ...rest } = signatureData;
|
||||
|
||||
let k162Type = null;
|
||||
let isEOL = false;
|
||||
if (custom_info) {
|
||||
const customInfo = JSON.parse(custom_info);
|
||||
k162Type = customInfo.k162Type;
|
||||
isEOL = customInfo.isEOL;
|
||||
}
|
||||
|
||||
signatureForm.reset({
|
||||
linked_system: linked_system?.solar_system_id.toString() ?? undefined,
|
||||
k162Type: k162Type,
|
||||
isEOL: isEOL,
|
||||
...rest,
|
||||
});
|
||||
}, [signatureForm, signatureData]);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { InputSwitch } from 'primereact/inputswitch';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { SystemSignature } from '@/hooks/Mapper/types';
|
||||
|
||||
export interface SignatureEOLCheckboxProps {
|
||||
name: string;
|
||||
defaultValue?: boolean;
|
||||
}
|
||||
|
||||
export const SignatureEOLCheckbox = ({ name, defaultValue = false }: SignatureEOLCheckboxProps) => {
|
||||
const { control } = useFormContext<SystemSignature>();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
// @ts-ignore
|
||||
name={name}
|
||||
control={control}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field }) => {
|
||||
return <InputSwitch className="my-1" checked={!!field.value} onChange={e => field.onChange(e.value)} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './SignatureEOLCheckbox.tsx';
|
||||
@@ -3,6 +3,7 @@ import { SystemSignature } from '@/hooks/Mapper/types';
|
||||
import { SignatureWormholeTypeSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureWormholeTypeSelect';
|
||||
import { SignatureK162TypeSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureK162TypeSelect';
|
||||
import { SignatureLeadsToSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureLeadsToSelect';
|
||||
import { SignatureEOLCheckbox } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureEOLCheckbox';
|
||||
|
||||
export const SignatureGroupContentWormholes = () => {
|
||||
const { watch } = useFormContext<SystemSignature>();
|
||||
@@ -26,6 +27,11 @@ export const SignatureGroupContentWormholes = () => {
|
||||
<span>Leads To:</span>
|
||||
<SignatureLeadsToSelect name="linked_system" />
|
||||
</label>
|
||||
|
||||
<label className="grid grid-cols-[100px_250px_1fr] gap-2 items-center text-[14px]">
|
||||
<span>EOL:</span>
|
||||
<SignatureEOLCheckbox name="isEOL" />
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,100 +3,8 @@ import clsx from 'clsx';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { useMemo } from 'react';
|
||||
import { SystemSignature } from '@/hooks/Mapper/types';
|
||||
import { WHClassView } from '@/hooks/Mapper/components/ui-kit';
|
||||
|
||||
export const k162Types = [
|
||||
{
|
||||
label: 'Hi-Sec',
|
||||
value: 'hs',
|
||||
whClassName: 'A641',
|
||||
},
|
||||
{
|
||||
label: 'Low-Sec',
|
||||
value: 'ls',
|
||||
whClassName: 'J377',
|
||||
},
|
||||
{
|
||||
label: 'Null-Sec',
|
||||
value: 'ns',
|
||||
whClassName: 'C248',
|
||||
},
|
||||
{
|
||||
label: 'C1',
|
||||
value: 'c1',
|
||||
whClassName: 'E004',
|
||||
},
|
||||
{
|
||||
label: 'C2',
|
||||
value: 'c2',
|
||||
whClassName: 'D382',
|
||||
},
|
||||
{
|
||||
label: 'C3',
|
||||
value: 'c3',
|
||||
whClassName: 'L477',
|
||||
},
|
||||
{
|
||||
label: 'C4',
|
||||
value: 'c4',
|
||||
whClassName: 'M001',
|
||||
},
|
||||
{
|
||||
label: 'C5',
|
||||
value: 'c5',
|
||||
whClassName: 'L614',
|
||||
},
|
||||
{
|
||||
label: 'C6',
|
||||
value: 'c6',
|
||||
whClassName: 'G008',
|
||||
},
|
||||
{
|
||||
label: 'C13',
|
||||
value: 'c13',
|
||||
whClassName: 'A009',
|
||||
},
|
||||
{
|
||||
label: 'Thera',
|
||||
value: 'thera',
|
||||
whClassName: 'F353',
|
||||
},
|
||||
{
|
||||
label: 'Pochven',
|
||||
value: 'pochven',
|
||||
whClassName: 'F216',
|
||||
},
|
||||
];
|
||||
|
||||
const renderNoValue = () => <div className="flex gap-2 items-center">-Unknown-</div>;
|
||||
|
||||
// @ts-ignore
|
||||
export const renderK162Type = (option: {
|
||||
label?: string;
|
||||
value: string;
|
||||
security?: string;
|
||||
system_class?: number;
|
||||
whClassName?: string;
|
||||
}) => {
|
||||
if (!option) {
|
||||
return renderNoValue();
|
||||
}
|
||||
const { value, whClassName = '' } = option;
|
||||
if (value == null) {
|
||||
return renderNoValue();
|
||||
}
|
||||
|
||||
return (
|
||||
<WHClassView
|
||||
classNameWh="!text-[11px] !font-bold"
|
||||
hideWhClassName
|
||||
hideTooltip
|
||||
whClassName={whClassName}
|
||||
noOffset
|
||||
useShortTitle
|
||||
/>
|
||||
);
|
||||
};
|
||||
import { K162_TYPES } from '@/hooks/Mapper/constants.ts';
|
||||
import { renderK162Type } from '.';
|
||||
|
||||
export interface SignatureK162TypeSelectProps {
|
||||
name: string;
|
||||
@@ -107,7 +15,7 @@ export const SignatureK162TypeSelect = ({ name, defaultValue = '' }: SignatureK1
|
||||
const { control } = useFormContext<SystemSignature>();
|
||||
|
||||
const options = useMemo(() => {
|
||||
return [{ value: null }, ...k162Types];
|
||||
return [{ value: null }, ...K162_TYPES];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './SignatureK162TypeSelect.tsx';
|
||||
export * from './renderK162Type.tsx';
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { WHClassView } from '@/hooks/Mapper/components/ui-kit';
|
||||
import { K162Type } from '@/hooks/Mapper/constants.ts';
|
||||
|
||||
const renderNoValue = () => <div className="flex gap-2 items-center">-Unknown-</div>;
|
||||
|
||||
export const renderK162Type = (option: K162Type) => {
|
||||
if (!option) {
|
||||
return renderNoValue();
|
||||
}
|
||||
|
||||
const { value, whClassName = '' } = option;
|
||||
if (value == null) {
|
||||
return renderNoValue();
|
||||
}
|
||||
|
||||
return (
|
||||
<WHClassView
|
||||
classNameWh="!text-[11px] !font-bold"
|
||||
hideWhClassName
|
||||
hideTooltip
|
||||
whClassName={whClassName}
|
||||
noOffset
|
||||
useShortTitle
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -14,9 +14,10 @@ import classes from './MapWrapper.module.scss';
|
||||
import { Connections } from '@/hooks/Mapper/components/mapRootContent/components/Connections';
|
||||
import { ContextMenuSystemMultiple, useContextMenuSystemMultipleHandlers } from '../contexts/ContextMenuSystemMultiple';
|
||||
import { getSystemById } from '@/hooks/Mapper/helpers';
|
||||
import { Commands } from '@/hooks/Mapper/types/mapHandlers.ts';
|
||||
import { Node, XYPosition } from 'reactflow';
|
||||
|
||||
import { Commands } from '@/hooks/Mapper/types/mapHandlers.ts';
|
||||
import { useCommandsSystems } from '@/hooks/Mapper/mapRootProvider/hooks/api';
|
||||
import { emitMapEvent, useMapEventListener } from '@/hooks/Mapper/events';
|
||||
|
||||
import { STORED_INTERFACE_DEFAULT_VALUES } from '@/hooks/Mapper/mapRootProvider/MapRootProvider';
|
||||
@@ -32,7 +33,7 @@ export const MapWrapper = () => {
|
||||
const {
|
||||
update,
|
||||
outCommand,
|
||||
data: { selectedConnections, selectedSystems, hubs, systems },
|
||||
data: { selectedConnections, selectedSystems, hubs, systems, connections, linkSignatureToSystem },
|
||||
interfaceSettings: {
|
||||
isShowMenu,
|
||||
isShowMinimap = STORED_INTERFACE_DEFAULT_VALUES.isShowMinimap,
|
||||
@@ -46,25 +47,19 @@ export const MapWrapper = () => {
|
||||
const { deleteSystems } = useDeleteSystems();
|
||||
const { mapRef, runCommand } = useCommonMapEventProcessor();
|
||||
|
||||
const { updateLinkSignatureToSystem } = useCommandsSystems();
|
||||
const { open, ...systemContextProps } = useContextMenuSystemHandlers({ systems, hubs, outCommand });
|
||||
const { handleSystemMultipleContext, ...systemMultipleCtxProps } = useContextMenuSystemMultipleHandlers();
|
||||
|
||||
const [openSettings, setOpenSettings] = useState<string | null>(null);
|
||||
const [openLinkSignatures, setOpenLinkSignatures] = useState<any | null>(null);
|
||||
const [openCustomLabel, setOpenCustomLabel] = useState<string | null>(null);
|
||||
const [openAddSystem, setOpenAddSystem] = useState<XYPosition | null>(null);
|
||||
const [selectedConnection, setSelectedConnection] = useState<SolarSystemConnection | null>(null);
|
||||
|
||||
const ref = useRef({ selectedConnections, selectedSystems, systemContextProps, systems, deleteSystems });
|
||||
ref.current = { selectedConnections, selectedSystems, systemContextProps, systems, deleteSystems };
|
||||
const ref = useRef({ selectedConnections, selectedSystems, systemContextProps, systems, connections, deleteSystems });
|
||||
ref.current = { selectedConnections, selectedSystems, systemContextProps, systems, connections, deleteSystems };
|
||||
|
||||
useMapEventListener(event => {
|
||||
switch (event.name) {
|
||||
case Commands.linkSignatureToSystem:
|
||||
setOpenLinkSignatures(event.data);
|
||||
return true;
|
||||
}
|
||||
|
||||
runCommand(event);
|
||||
});
|
||||
|
||||
@@ -93,9 +88,6 @@ export const MapWrapper = () => {
|
||||
case OutCommand.openSettings:
|
||||
setOpenSettings(event.data.system_id);
|
||||
break;
|
||||
case OutCommand.linkSignatureToSystem:
|
||||
setOpenLinkSignatures(event.data);
|
||||
break;
|
||||
default:
|
||||
return outCommand(event);
|
||||
}
|
||||
@@ -133,6 +125,11 @@ export const MapWrapper = () => {
|
||||
setOpenAddSystem(coordinates);
|
||||
}, []);
|
||||
|
||||
const canRemoveConnection = useCallback((connectionId: string) => {
|
||||
const { connections } = ref.current;
|
||||
return !connections.some(x => x.id === connectionId);
|
||||
}, []);
|
||||
|
||||
const handleSubmitAddSystem: SearchOnSubmitCallback = useCallback(
|
||||
async item => {
|
||||
if (ref.current.systems.some(x => x.system_static_info.solar_system_id === item.value)) {
|
||||
@@ -169,6 +166,7 @@ export const MapWrapper = () => {
|
||||
isSoftBackground={isSoftBackground}
|
||||
theme={theme}
|
||||
onAddSystem={onAddSystem}
|
||||
canRemoveConnection={canRemoveConnection}
|
||||
/>
|
||||
|
||||
{openSettings != null && (
|
||||
@@ -179,8 +177,8 @@ export const MapWrapper = () => {
|
||||
<SystemCustomLabelDialog systemId={openCustomLabel} visible setVisible={() => setOpenCustomLabel(null)} />
|
||||
)}
|
||||
|
||||
{openLinkSignatures != null && (
|
||||
<SystemLinkSignatureDialog data={openLinkSignatures} setVisible={() => setOpenLinkSignatures(null)} />
|
||||
{linkSignatureToSystem != null && (
|
||||
<SystemLinkSignatureDialog data={linkSignatureToSystem} setVisible={() => updateLinkSignatureToSystem(null)} />
|
||||
)}
|
||||
|
||||
<AddSystemDialog
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
border-style: solid;
|
||||
border-color: #272727;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
border-radius: 3px;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.CharName {
|
||||
@@ -26,7 +26,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
.CharIcon {}
|
||||
.CharIcon {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.CharRow {
|
||||
display: grid;
|
||||
|
||||
@@ -3,7 +3,7 @@ import clsx from 'clsx';
|
||||
import classes from './CharacterCard.module.scss';
|
||||
import { SystemView } from '@/hooks/Mapper/components/ui-kit/SystemView';
|
||||
import { CharacterTypeRaw, WithIsOwnCharacter } from '@/hooks/Mapper/types';
|
||||
import { Commands } from '@/hooks/Mapper/types/mapHandlers.ts';
|
||||
import { Commands } from '@/hooks/Mapper/types/mapHandlers';
|
||||
import { emitMapEvent } from '@/hooks/Mapper/events';
|
||||
|
||||
type CharacterCardProps = {
|
||||
@@ -18,12 +18,8 @@ const SHIP_NAME_RX = /u'|'/g;
|
||||
export const getShipName = (name: string) => {
|
||||
return name
|
||||
.replace(SHIP_NAME_RX, '')
|
||||
.replace(/\\u([\dA-Fa-f]{4})/g, (_, grp) => {
|
||||
return String.fromCharCode(parseInt(grp, 16));
|
||||
})
|
||||
.replace(/\\x([\dA-Fa-f]{2})/g, (_, grp) => {
|
||||
return String.fromCharCode(parseInt(grp, 16));
|
||||
});
|
||||
.replace(/\\u([\dA-Fa-f]{4})/g, (_, grp) => String.fromCharCode(parseInt(grp, 16)))
|
||||
.replace(/\\x([\dA-Fa-f]{2})/g, (_, grp) => String.fromCharCode(parseInt(grp, 16)));
|
||||
};
|
||||
|
||||
export const CharacterCard = ({
|
||||
@@ -47,7 +43,9 @@ export const CharacterCard = ({
|
||||
{!compact && (
|
||||
<span
|
||||
className={clsx(classes.EveIcon, classes.CharIcon, 'wd-bg-default')}
|
||||
style={{ backgroundImage: `url(https://images.evetech.net/characters/${char.eve_id}/portrait)` }}
|
||||
style={{
|
||||
backgroundImage: `url(https://images.evetech.net/characters/${char.eve_id}/portrait)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col flex-grow">
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
import React, {
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
MouseEvent,
|
||||
MouseEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import React, { ForwardedRef, forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import classes from './WdTooltip.module.scss';
|
||||
import clsx from 'clsx';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { WithClassName } from '@/hooks/Mapper/types/common.ts';
|
||||
import classes from './WdTooltip.module.scss';
|
||||
|
||||
export enum TooltipPosition {
|
||||
default = 'default',
|
||||
@@ -29,11 +17,7 @@ export interface TooltipProps {
|
||||
offset?: number;
|
||||
content: (() => React.ReactNode) | React.ReactNode;
|
||||
targetSelector?: string;
|
||||
}
|
||||
|
||||
export interface WdTooltipHandlers {
|
||||
show: MouseEventHandler;
|
||||
hide: MouseEventHandler;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export interface OffsetPosition {
|
||||
@@ -41,169 +25,185 @@ export interface OffsetPosition {
|
||||
left: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
export const WdTooltip = forwardRef((props: TooltipProps & WithClassName, ref: ForwardedRef<WdTooltipHandlers>) => {
|
||||
const { content, targetSelector, position: tPosition = TooltipPosition.default, className, offset = 5 } = props;
|
||||
export interface WdTooltipHandlers {
|
||||
show: (e?: React.MouseEvent) => void;
|
||||
hide: (e?: React.MouseEvent) => void;
|
||||
getIsMouseInside: () => boolean;
|
||||
}
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [position, setPosition] = useState<OffsetPosition | null>(null);
|
||||
const [ev, setEv] = useState<MouseEvent>();
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
export const WdTooltip = forwardRef(
|
||||
(props: TooltipProps & { className?: string }, ref: ForwardedRef<WdTooltipHandlers>) => {
|
||||
const {
|
||||
content,
|
||||
targetSelector,
|
||||
position: tPosition = TooltipPosition.default,
|
||||
className,
|
||||
offset = 5,
|
||||
interactive = false,
|
||||
} = props;
|
||||
|
||||
const calcTooltipPosition = useCallback(({ x, y }: { x: number; y: number }) => {
|
||||
let newLeft = x;
|
||||
let newTop = y;
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [pos, setPos] = useState<OffsetPosition | null>(null);
|
||||
const [ev, setEv] = useState<React.MouseEvent>();
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const [isMouseInsideTooltip, setIsMouseInsideTooltip] = useState(false);
|
||||
|
||||
if (!tooltipRef.current) {
|
||||
const calcTooltipPosition = useCallback(({ x, y }: { x: number; y: number }) => {
|
||||
if (!tooltipRef.current) return { left: x, top: y };
|
||||
const tooltipWidth = tooltipRef.current.offsetWidth;
|
||||
const tooltipHeight = tooltipRef.current.offsetHeight;
|
||||
let newLeft = x;
|
||||
let newTop = y;
|
||||
|
||||
if (newLeft < 0) newLeft = 10;
|
||||
if (newTop < 0) newTop = 10;
|
||||
if (newLeft + tooltipWidth + 10 > window.innerWidth) {
|
||||
newLeft = window.innerWidth - tooltipWidth - 10;
|
||||
}
|
||||
if (newTop + tooltipHeight + 10 > window.innerHeight) {
|
||||
newTop = window.innerHeight - tooltipHeight - 10;
|
||||
}
|
||||
return { left: newLeft, top: newTop };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const tooltipWidth = tooltipRef.current.offsetWidth;
|
||||
const tooltipHeight = tooltipRef.current.offsetHeight;
|
||||
useImperativeHandle(ref, () => ({
|
||||
show: (mouseEvt?: React.MouseEvent) => {
|
||||
if (mouseEvt) setEv(mouseEvt);
|
||||
setPos(null);
|
||||
setVisible(true);
|
||||
},
|
||||
hide: () => {
|
||||
setVisible(false);
|
||||
},
|
||||
getIsMouseInside: () => isMouseInsideTooltip,
|
||||
}));
|
||||
|
||||
if (newLeft < 0) {
|
||||
newLeft = 10;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!tooltipRef.current || !ev) return;
|
||||
const tooltipEl = tooltipRef.current;
|
||||
const { clientX, clientY, target } = ev;
|
||||
const targetBounds = (target as HTMLElement).getBoundingClientRect();
|
||||
|
||||
if (newTop < 0) {
|
||||
newTop = 10;
|
||||
}
|
||||
let offsetX = clientX;
|
||||
let offsetY = clientY;
|
||||
|
||||
if (newLeft + tooltipWidth + 10 > window.innerWidth) {
|
||||
newLeft = window.innerWidth - tooltipWidth - 10;
|
||||
}
|
||||
if (newTop + tooltipHeight + 10 > window.innerHeight) {
|
||||
newTop = window.innerHeight - tooltipHeight - 10;
|
||||
}
|
||||
return { left: newLeft, top: newTop };
|
||||
}, []);
|
||||
if (tPosition === TooltipPosition.left) {
|
||||
const tooltipBounds = tooltipEl.getBoundingClientRect();
|
||||
offsetX = targetBounds.left - tooltipBounds.width - offset;
|
||||
offsetY = targetBounds.y + targetBounds.height / 2 - tooltipBounds.height / 2;
|
||||
if (offsetX <= 0) {
|
||||
offsetX = targetBounds.left + targetBounds.width + offset;
|
||||
}
|
||||
setPos(calcTooltipPosition({ x: offsetX, y: offsetY }));
|
||||
return;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!tooltipRef.current || !ev) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { clientX, clientY, target } = ev;
|
||||
|
||||
const targetBounds = (target as HTMLElement).getBoundingClientRect();
|
||||
const tooltipBounds = tooltipRef.current.getBoundingClientRect();
|
||||
|
||||
let offsetX = clientX;
|
||||
let offsetY = clientY;
|
||||
|
||||
if (tPosition === TooltipPosition.left) {
|
||||
offsetX = targetBounds.left - tooltipBounds.width - offset;
|
||||
offsetY = targetBounds.y + targetBounds.height / 2 - tooltipBounds.height / 2;
|
||||
|
||||
if (offsetX <= 0) {
|
||||
if (tPosition === TooltipPosition.right) {
|
||||
offsetX = targetBounds.left + targetBounds.width + offset;
|
||||
}
|
||||
|
||||
setPosition(calcTooltipPosition({ x: offsetX, y: offsetY }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (tPosition === TooltipPosition.right) {
|
||||
offsetX = targetBounds.left + targetBounds.width + offset;
|
||||
offsetY = targetBounds.y + targetBounds.height / 2 - tooltipBounds.height / 2;
|
||||
|
||||
setPosition(calcTooltipPosition({ x: offsetX, y: offsetY }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (tPosition === TooltipPosition.top) {
|
||||
offsetY = targetBounds.top - tooltipBounds.height - offset;
|
||||
offsetX = targetBounds.x + targetBounds.width / 2 - tooltipBounds.width / 2;
|
||||
|
||||
setPosition(calcTooltipPosition({ x: offsetX, y: offsetY }));
|
||||
return;
|
||||
}
|
||||
|
||||
// default case
|
||||
setPosition(calcTooltipPosition({ x: clientX, y: clientY }));
|
||||
}, [calcTooltipPosition, ev, tPosition, offset]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
show: e => {
|
||||
setEv(e);
|
||||
setVisible(true);
|
||||
setPosition(null);
|
||||
},
|
||||
hide: () => {
|
||||
setVisible(false);
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (targetSelector == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const targetElement = e.target as HTMLElement;
|
||||
|
||||
if (!targetElement) {
|
||||
setVisible(false);
|
||||
offsetY = targetBounds.y + targetBounds.height / 2 - tooltipEl.offsetHeight / 2;
|
||||
setPos(calcTooltipPosition({ x: offsetX, y: offsetY }));
|
||||
return;
|
||||
}
|
||||
|
||||
const nodesFound = [...(targetElement?.parentElement?.querySelectorAll(targetSelector) ?? [])];
|
||||
|
||||
if (!nodesFound.includes(targetElement)) {
|
||||
setVisible(false);
|
||||
if (tPosition === TooltipPosition.top) {
|
||||
offsetY = targetBounds.top - tooltipEl.offsetHeight - offset;
|
||||
offsetX = targetBounds.x + targetBounds.width / 2 - tooltipEl.offsetWidth / 2;
|
||||
setPos(calcTooltipPosition({ x: offsetX, y: offsetY }));
|
||||
return;
|
||||
}
|
||||
|
||||
setVisible(true);
|
||||
if (tooltipRef.current) {
|
||||
const { clientX, clientY } = e;
|
||||
const tooltipWidth = tooltipRef.current.offsetWidth;
|
||||
const tooltipHeight = tooltipRef.current.offsetHeight;
|
||||
let newLeft = clientX + 10;
|
||||
let newTop = clientY + 10;
|
||||
if (newLeft + tooltipWidth + 10 > window.innerWidth) {
|
||||
newLeft = window.innerWidth - tooltipWidth - 10;
|
||||
}
|
||||
if (newTop + tooltipHeight + 10 > window.innerHeight) {
|
||||
newTop = window.innerHeight - tooltipHeight - 10;
|
||||
}
|
||||
setPosition({ top: newTop, left: newLeft });
|
||||
if (tPosition === TooltipPosition.bottom) {
|
||||
offsetY = targetBounds.bottom + offset;
|
||||
offsetX = targetBounds.x + targetBounds.width / 2 - tooltipEl.offsetWidth / 2;
|
||||
setPos(calcTooltipPosition({ x: offsetX, y: offsetY }));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const deb = debounce(handleMouseMove, 10);
|
||||
setPos(calcTooltipPosition({ x: offsetX, y: offsetY }));
|
||||
}, [calcTooltipPosition, ev, tPosition, offset]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
document.addEventListener('mousemove', deb);
|
||||
return () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
document.removeEventListener('mousemove', deb);
|
||||
};
|
||||
}, [targetSelector]);
|
||||
useEffect(() => {
|
||||
if (!targetSelector) return;
|
||||
|
||||
return createPortal(
|
||||
visible && (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className={clsx(
|
||||
classes.tooltip,
|
||||
'pointer-events-none',
|
||||
'absolute px-2 py-2',
|
||||
'border rounded border-green-300 border-opacity-10 bg-stone-900 bg-opacity-90',
|
||||
{ ['invisible']: position === null },
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
top: position?.top ?? 0,
|
||||
left: position?.left ?? 0,
|
||||
zIndex: 10000,
|
||||
}}
|
||||
>
|
||||
{typeof content === 'function' ? content() : content}
|
||||
</div>
|
||||
),
|
||||
document.body,
|
||||
);
|
||||
});
|
||||
function handleMouseMove(nativeEvt: globalThis.MouseEvent) {
|
||||
const targetEl = nativeEvt.target as HTMLElement | null;
|
||||
if (!targetEl) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
const triggerEl = targetEl.closest(targetSelector!);
|
||||
const isInsideTooltip = interactive && tooltipRef.current?.contains(targetEl);
|
||||
|
||||
if (!triggerEl && !isInsideTooltip) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
setVisible(true);
|
||||
|
||||
if (triggerEl && tooltipRef.current) {
|
||||
const rect = triggerEl.getBoundingClientRect();
|
||||
const tooltipEl = tooltipRef.current;
|
||||
let x = nativeEvt.clientX;
|
||||
let y = nativeEvt.clientY;
|
||||
|
||||
if (tPosition === TooltipPosition.left) {
|
||||
x = rect.left - tooltipEl.offsetWidth - offset;
|
||||
y = rect.y + rect.height / 2 - tooltipEl.offsetHeight / 2;
|
||||
if (x <= 0) {
|
||||
x = rect.left + rect.width + offset;
|
||||
}
|
||||
} else if (tPosition === TooltipPosition.right) {
|
||||
x = rect.left + rect.width + offset;
|
||||
y = rect.y + rect.height / 2 - tooltipEl.offsetHeight / 2;
|
||||
} else if (tPosition === TooltipPosition.top) {
|
||||
x = rect.x + rect.width / 2 - tooltipEl.offsetWidth / 2;
|
||||
y = rect.top - tooltipEl.offsetHeight - offset;
|
||||
} else if (tPosition === TooltipPosition.bottom) {
|
||||
x = rect.x + rect.width / 2 - tooltipEl.offsetWidth / 2;
|
||||
y = rect.bottom + offset;
|
||||
}
|
||||
|
||||
setPos(calcTooltipPosition({ x, y }));
|
||||
}
|
||||
}
|
||||
|
||||
const debounced = debounce(handleMouseMove, 10);
|
||||
|
||||
const listener: EventListener = evt => {
|
||||
debounced(evt as globalThis.MouseEvent);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', listener);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', listener);
|
||||
};
|
||||
}, [targetSelector, interactive, tPosition, offset, calcTooltipPosition]);
|
||||
|
||||
return createPortal(
|
||||
visible && (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className={clsx(
|
||||
classes.tooltip,
|
||||
interactive ? 'pointer-events-auto' : 'pointer-events-none',
|
||||
'absolute p-1 border rounded-sm border-green-300 border-opacity-10 bg-stone-900 bg-opacity-90',
|
||||
pos === null ? 'invisible' : '',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
top: pos?.top ?? 0,
|
||||
left: pos?.left ?? 0,
|
||||
zIndex: 10000,
|
||||
}}
|
||||
onMouseEnter={() => interactive && setIsMouseInsideTooltip(true)}
|
||||
onMouseLeave={() => interactive && setIsMouseInsideTooltip(false)}
|
||||
>
|
||||
{typeof content === 'function' ? content() : content}
|
||||
</div>
|
||||
),
|
||||
document.body,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
WdTooltip.displayName = 'WdTooltip';
|
||||
|
||||
@@ -1,49 +1,57 @@
|
||||
import React, { HTMLProps, MouseEventHandler, useCallback, useRef } from 'react';
|
||||
|
||||
import classes from './WdTooltipWrapper.module.scss';
|
||||
import { WithChildren, WithClassName } from '@/hooks/Mapper/types/common.ts';
|
||||
import { TooltipProps, WdTooltip, WdTooltipHandlers } from '@/hooks/Mapper/components/ui-kit';
|
||||
import { forwardRef, HTMLProps, ReactNode } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { WdTooltip, WdTooltipHandlers, TooltipProps } from '@/hooks/Mapper/components/ui-kit';
|
||||
import classes from './WdTooltipWrapper.module.scss';
|
||||
|
||||
type TooltipSize = 'xs' | 'sm' | 'md' | 'lg';
|
||||
|
||||
export type WdTooltipWrapperProps = {
|
||||
content?: (() => React.ReactNode) | React.ReactNode;
|
||||
} & WithChildren &
|
||||
WithClassName &
|
||||
HTMLProps<HTMLDivElement> &
|
||||
content?: (() => ReactNode) | ReactNode;
|
||||
size?: TooltipSize;
|
||||
interactive?: boolean;
|
||||
} & Omit<HTMLProps<HTMLDivElement>, 'content' | 'size'> &
|
||||
Omit<TooltipProps, 'content'>;
|
||||
|
||||
export const WdTooltipWrapper = ({
|
||||
className,
|
||||
children,
|
||||
content,
|
||||
offset,
|
||||
position,
|
||||
targetSelector,
|
||||
...props
|
||||
}: WdTooltipWrapperProps) => {
|
||||
const tooltipRef = useRef<WdTooltipHandlers>(null);
|
||||
const handleShowDeleteTooltip: MouseEventHandler = useCallback(e => tooltipRef.current?.show(e), []);
|
||||
const handleHideDeleteTooltip: MouseEventHandler = useCallback(e => tooltipRef.current?.hide(e), []);
|
||||
export const WdTooltipWrapper = forwardRef<WdTooltipHandlers, WdTooltipWrapperProps>(
|
||||
(
|
||||
{ className, children, content, offset, position, targetSelector, interactive = false, size, ...props },
|
||||
forwardedRef,
|
||||
) => {
|
||||
const suffix = Math.random().toString(36).slice(2, 7);
|
||||
const autoClass = `wdTooltipAutoTrigger-${suffix}`;
|
||||
const finalTargetSelector = targetSelector || `.${autoClass}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(classes.WdTooltipWrapperRoot, className)}
|
||||
{...props}
|
||||
{...(content && {
|
||||
onMouseEnter: handleShowDeleteTooltip,
|
||||
onMouseLeave: handleHideDeleteTooltip,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
return (
|
||||
<div className={clsx(classes.WdTooltipWrapperRoot, className)} {...props}>
|
||||
{targetSelector ? <>{children}</> : <div className={autoClass}>{children}</div>}
|
||||
|
||||
<WdTooltip
|
||||
ref={forwardedRef}
|
||||
offset={offset}
|
||||
position={position}
|
||||
content={content}
|
||||
interactive={interactive}
|
||||
targetSelector={finalTargetSelector}
|
||||
className={size ? sizeClass(size) : undefined}
|
||||
/>
|
||||
</div>
|
||||
<WdTooltip
|
||||
ref={tooltipRef}
|
||||
offset={offset}
|
||||
position={position}
|
||||
content={content}
|
||||
targetSelector={targetSelector}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
WdTooltipWrapper.displayName = 'WdTooltipWrapper';
|
||||
|
||||
function sizeClass(size: TooltipSize) {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return classes.wdTooltipSizeXs;
|
||||
case 'sm':
|
||||
return classes.wdTooltipSizeSm;
|
||||
case 'md':
|
||||
return classes.wdTooltipSizeMd;
|
||||
case 'lg':
|
||||
return classes.wdTooltipSizeLg;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,14 +76,22 @@ export const WindowWrapper = ({ onResize, onDrag, ...window }: WindowWrapperProp
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export type ViewPortProps = { w: number; h: number };
|
||||
export type WindowsManagerOnChange = (props: { windows: WindowProps[]; viewPort: ViewPortProps }) => void;
|
||||
|
||||
type WindowManagerProps = {
|
||||
windows: WindowProps[];
|
||||
viewPort?: ViewPortProps;
|
||||
dragSelector?: string;
|
||||
onChange?(windows: WindowProps[]): void;
|
||||
onChange?: WindowsManagerOnChange;
|
||||
};
|
||||
|
||||
export const WindowManager: React.FC<WindowManagerProps> = ({ windows: initialWindows, dragSelector, onChange }) => {
|
||||
export const WindowManager: React.FC<WindowManagerProps> = ({
|
||||
windows: initialWindows,
|
||||
viewPort,
|
||||
dragSelector,
|
||||
onChange,
|
||||
}) => {
|
||||
const [windows, setWindows] = useState(
|
||||
initialWindows.map((window, index) => ({
|
||||
...window,
|
||||
@@ -91,6 +99,16 @@ export const WindowManager: React.FC<WindowManagerProps> = ({ windows: initialWi
|
||||
})),
|
||||
);
|
||||
|
||||
const refPrevSize = useRef({ w: 0, h: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewPort) {
|
||||
return;
|
||||
}
|
||||
|
||||
refPrevSize.current = viewPort;
|
||||
}, [viewPort]);
|
||||
|
||||
useEffect(() => {
|
||||
setWindows(initialWindows.slice(0));
|
||||
}, [initialWindows]);
|
||||
@@ -102,14 +120,15 @@ export const WindowManager: React.FC<WindowManagerProps> = ({ windows: initialWi
|
||||
const startMousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const startWindowStateRef = useRef<{ x: number; y: number; width: number; height: number }>(DefaultWindowState);
|
||||
|
||||
const ref = useRef({ windows, onChange });
|
||||
ref.current = { windows, onChange };
|
||||
|
||||
const refPrevSize = useRef({ w: 0, h: 0 });
|
||||
const ref = useRef({ windows, viewPort, onChange });
|
||||
ref.current = { windows, viewPort, onChange };
|
||||
|
||||
const onDebouncedChange = useMemo(() => {
|
||||
return debounce(() => {
|
||||
ref.current.onChange?.(ref.current.windows);
|
||||
ref.current.onChange?.({
|
||||
windows: ref.current.windows,
|
||||
viewPort: refPrevSize.current,
|
||||
});
|
||||
}, 20);
|
||||
}, []);
|
||||
|
||||
@@ -336,7 +355,7 @@ export const WindowManager: React.FC<WindowManagerProps> = ({ windows: initialWi
|
||||
|
||||
// Handle resize of the container and reposition windows
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
if (ref.current.viewPort == null && containerRef.current) {
|
||||
refPrevSize.current = { w: containerRef.current.clientWidth, h: containerRef.current.clientHeight };
|
||||
}
|
||||
|
||||
@@ -384,6 +403,14 @@ export const WindowManager: React.FC<WindowManagerProps> = ({ windows: initialWi
|
||||
next.position.y = container.clientHeight - next.size.height - SNAP_GAP;
|
||||
}
|
||||
|
||||
if (next.position.y < SNAP_GAP) {
|
||||
next.position.y = 0;
|
||||
}
|
||||
|
||||
if (next.position.x < SNAP_GAP) {
|
||||
next.position.x = SNAP_GAP;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,3 +65,77 @@ export const REGIONS_MAP: Record<number, Spaces> = {
|
||||
[Regions.TashMurkon]: Spaces.Amarr,
|
||||
[Regions.VergeVendor]: Spaces.Gallente,
|
||||
};
|
||||
|
||||
export type K162Type = {
|
||||
label: string;
|
||||
value: string;
|
||||
whClassName: string;
|
||||
};
|
||||
|
||||
export const K162_TYPES: K162Type[] = [
|
||||
{
|
||||
label: 'Hi-Sec',
|
||||
value: 'hs',
|
||||
whClassName: 'A641',
|
||||
},
|
||||
{
|
||||
label: 'Low-Sec',
|
||||
value: 'ls',
|
||||
whClassName: 'J377',
|
||||
},
|
||||
{
|
||||
label: 'Null-Sec',
|
||||
value: 'ns',
|
||||
whClassName: 'C248',
|
||||
},
|
||||
{
|
||||
label: 'C1',
|
||||
value: 'c1',
|
||||
whClassName: 'E004',
|
||||
},
|
||||
{
|
||||
label: 'C2',
|
||||
value: 'c2',
|
||||
whClassName: 'D382',
|
||||
},
|
||||
{
|
||||
label: 'C3',
|
||||
value: 'c3',
|
||||
whClassName: 'L477',
|
||||
},
|
||||
{
|
||||
label: 'C4',
|
||||
value: 'c4',
|
||||
whClassName: 'M001',
|
||||
},
|
||||
{
|
||||
label: 'C5',
|
||||
value: 'c5',
|
||||
whClassName: 'L614',
|
||||
},
|
||||
{
|
||||
label: 'C6',
|
||||
value: 'c6',
|
||||
whClassName: 'G008',
|
||||
},
|
||||
{
|
||||
label: 'C13',
|
||||
value: 'c13',
|
||||
whClassName: 'A009',
|
||||
},
|
||||
{
|
||||
label: 'Thera',
|
||||
value: 'thera',
|
||||
whClassName: 'F353',
|
||||
},
|
||||
{
|
||||
label: 'Pochven',
|
||||
value: 'pochven',
|
||||
whClassName: 'F216',
|
||||
},
|
||||
];
|
||||
|
||||
export const K162_TYPES_MAP: { [key: string]: K162Type } = K162_TYPES.reduce(
|
||||
(acc, x) => ({ ...acc, [x.value]: x }),
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SignatureCustomInfo } from '@/hooks/Mapper/types';
|
||||
|
||||
export const parseSignatureCustomInfo = (str: string | undefined): SignatureCustomInfo => {
|
||||
if (str == null || str === '') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return JSON.parse(str);
|
||||
};
|
||||
@@ -1,19 +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 { 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 = {
|
||||
@@ -29,11 +37,12 @@ const INITIAL_DATA: MapRootData = {
|
||||
routes: undefined,
|
||||
kills: [],
|
||||
connections: [],
|
||||
|
||||
detailedKills: {},
|
||||
selectedSystems: [],
|
||||
selectedConnections: [],
|
||||
userPermissions: {},
|
||||
options: {},
|
||||
linkSignatureToSystem: null,
|
||||
};
|
||||
|
||||
export enum InterfaceStoredSettingsProps {
|
||||
@@ -77,7 +86,7 @@ export interface MapRootContextProps {
|
||||
setInterfaceSettings: Dispatch<SetStateAction<InterfaceStoredSettings>>;
|
||||
windowsSettings: WindowStoreInfo;
|
||||
toggleWidgetVisibility: ToggleWidgetVisibility;
|
||||
updateWidgetSettings: UpdateWidgetSettingsFunc;
|
||||
updateWidgetSettings: WindowsManagerOnChange;
|
||||
resetWidgets: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { CommandAddSystems, CommandRemoveSystems, CommandUpdateSystems } from '@/hooks/Mapper/types';
|
||||
import {
|
||||
CommandAddSystems,
|
||||
CommandRemoveSystems,
|
||||
CommandUpdateSystems,
|
||||
CommandLinkSignatureToSystem,
|
||||
} from '@/hooks/Mapper/types';
|
||||
import { useLoadSystemStatic } from '@/hooks/Mapper/mapRootProvider/hooks/useLoadSystemStatic.ts';
|
||||
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;
|
||||
@@ -74,5 +80,28 @@ export const useCommandsSystems = () => {
|
||||
[outCommand],
|
||||
);
|
||||
|
||||
return { addSystems, removeSystems, updateSystems, updateSystemSignatures };
|
||||
const updateLinkSignatureToSystem = useCallback(async (command: CommandLinkSignatureToSystem) => {
|
||||
const { update } = ref.current;
|
||||
update({ linkSignatureToSystem: command }, true);
|
||||
}, []);
|
||||
|
||||
const updateDetailedKills = useCallback((newKillsMap: Record<string, DetailedKill[]>) => {
|
||||
const { update, detailedKills } = ref.current;
|
||||
|
||||
const updated = { ...detailedKills };
|
||||
for (const [systemId, killsArr] of Object.entries(newKillsMap)) {
|
||||
updated[systemId] = killsArr;
|
||||
}
|
||||
|
||||
update({ detailedKills: updated }, true);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
addSystems,
|
||||
removeSystems,
|
||||
updateSystems,
|
||||
updateSystemSignatures,
|
||||
updateLinkSignatureToSystem,
|
||||
updateDetailedKills,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CommandCharactersUpdated,
|
||||
CommandCharacterUpdated,
|
||||
CommandInit,
|
||||
CommandLinkSignatureToSystem,
|
||||
CommandMapUpdated,
|
||||
CommandPresentCharacters,
|
||||
CommandRemoveConnections,
|
||||
@@ -29,10 +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 } = useCommandsSystems();
|
||||
const {
|
||||
addSystems,
|
||||
removeSystems,
|
||||
updateSystems,
|
||||
updateSystemSignatures,
|
||||
updateLinkSignatureToSystem,
|
||||
updateDetailedKills,
|
||||
} = useCommandsSystems();
|
||||
const { addConnections, removeConnections, updateConnection } = useCommandsConnections();
|
||||
const { charactersUpdated, characterAdded, characterRemoved, characterUpdated, presentCharacters } =
|
||||
useCommandsCharacters();
|
||||
@@ -93,7 +102,9 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
|
||||
break;
|
||||
|
||||
case Commands.linkSignatureToSystem: // USED
|
||||
// do nothing here
|
||||
setTimeout(() => {
|
||||
updateLinkSignatureToSystem(data as CommandLinkSignatureToSystem);
|
||||
}, 200);
|
||||
break;
|
||||
|
||||
case Commands.centerSystem: // USED
|
||||
@@ -108,6 +119,10 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
|
||||
// do nothing here
|
||||
break;
|
||||
|
||||
case Commands.detailedKillsUpdated:
|
||||
updateDetailedKills(data as Record<string, DetailedKill[]>);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`JOipP Interface handlers: Unknown command: ${type}`, data);
|
||||
break;
|
||||
|
||||
@@ -8,13 +8,14 @@ import {
|
||||
} from '@/hooks/Mapper/components/mapInterface/constants.tsx';
|
||||
import { WindowProps } from '@/hooks/Mapper/components/ui-kit/WindowManager/types.ts';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { SNAP_GAP } from '@/hooks/Mapper/components/ui-kit/WindowManager';
|
||||
import { SNAP_GAP, WindowsManagerOnChange } from '@/hooks/Mapper/components/ui-kit/WindowManager';
|
||||
|
||||
export type StoredWindowProps = Omit<WindowProps, 'content'>;
|
||||
export type WindowStoreInfo = {
|
||||
version: number;
|
||||
windows: StoredWindowProps[];
|
||||
visible: WidgetsIds[];
|
||||
viewPort?: { w: number; h: number } | undefined;
|
||||
};
|
||||
export type UpdateWidgetSettingsFunc = (widgets: WindowProps[]) => void;
|
||||
export type ToggleWidgetVisibility = (widgetId: WidgetsIds) => void;
|
||||
@@ -33,7 +34,7 @@ export const useStoreWidgets = () => {
|
||||
const ref = useRef({ windowsSettings, setWindowsSettings });
|
||||
ref.current = { windowsSettings, setWindowsSettings };
|
||||
|
||||
const updateWidgetSettings: UpdateWidgetSettingsFunc = useCallback(newWindows => {
|
||||
const updateWidgetSettings: WindowsManagerOnChange = useCallback(({ windows, viewPort }) => {
|
||||
const { setWindowsSettings } = ref.current;
|
||||
|
||||
setWindowsSettings(({ version, visible /*, windows*/ }: WindowStoreInfo) => {
|
||||
@@ -41,13 +42,14 @@ export const useStoreWidgets = () => {
|
||||
version,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
windows: DEFAULT_WIDGETS.map(({ content, ...x }) => {
|
||||
const windowProp = newWindows.find(j => j.id === x.id);
|
||||
const windowProp = windows.find(j => j.id === x.id);
|
||||
if (windowProp) {
|
||||
return windowProp;
|
||||
}
|
||||
|
||||
return x;
|
||||
}),
|
||||
viewPort,
|
||||
visible,
|
||||
};
|
||||
});
|
||||
@@ -92,7 +94,7 @@ export const useStoreWidgets = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { version, windows, visible } = JSON.parse(raw) as WindowStoreInfo;
|
||||
const { version, windows, visible, viewPort } = JSON.parse(raw) as WindowStoreInfo;
|
||||
if (!version || CURRENT_WINDOWS_VERSION > version) {
|
||||
setWindowsSettings(getDefaultWidgetProps());
|
||||
}
|
||||
@@ -104,6 +106,7 @@ export const useStoreWidgets = () => {
|
||||
version: CURRENT_WINDOWS_VERSION,
|
||||
windows: out as WindowProps[],
|
||||
visible,
|
||||
viewPort,
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -2,3 +2,38 @@ export type Kill = {
|
||||
solar_system_id: number;
|
||||
kills: number;
|
||||
};
|
||||
|
||||
export interface DetailedKill {
|
||||
killmail_id: number;
|
||||
solar_system_id: number;
|
||||
kill_time?: string;
|
||||
|
||||
zkb?: Record<string, unknown>;
|
||||
|
||||
victim_char_id?: number | null;
|
||||
victim_char_name?: string;
|
||||
victim_corp_id?: number | null;
|
||||
victim_corp_ticker?: string;
|
||||
victim_corp_name?: string;
|
||||
victim_alliance_id?: number | null;
|
||||
victim_alliance_ticker?: string;
|
||||
victim_alliance_name?: string;
|
||||
victim_ship_type_id?: number | null;
|
||||
victim_ship_name?: string;
|
||||
|
||||
|
||||
final_blow_char_id?: number | null;
|
||||
final_blow_char_name?: string;
|
||||
final_blow_corp_id?: number | null;
|
||||
final_blow_corp_ticker?: string;
|
||||
final_blow_corp_name?: string;
|
||||
final_blow_alliance_id?: number | null;
|
||||
final_blow_alliance_ticker?: string;
|
||||
final_blow_alliance_name?: string;
|
||||
final_blow_ship_type_id?: number | null;
|
||||
final_blow_ship_name?: string;
|
||||
|
||||
attacker_count?: number | null;
|
||||
total_value?: number | null;
|
||||
npc?: boolean;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { SolarSystemConnection } from '@/hooks/Mapper/types/connection.ts';
|
||||
import { WormholeDataRaw } from '@/hooks/Mapper/types/wormholes.ts';
|
||||
import { CharacterTypeRaw } from '@/hooks/Mapper/types/character.ts';
|
||||
import { RoutesList } from '@/hooks/Mapper/types/routes.ts';
|
||||
import { Kill } from '@/hooks/Mapper/types/kills.ts';
|
||||
import { UserPermissions } from '@/hooks/Mapper/types';
|
||||
import { DetailedKill, Kill } from '@/hooks/Mapper/types/kills.ts';
|
||||
import { SignatureGroup, UserPermissions } from '@/hooks/Mapper/types';
|
||||
|
||||
export enum Commands {
|
||||
init = 'init',
|
||||
@@ -21,6 +21,7 @@ export enum Commands {
|
||||
updateConnection = 'update_connection',
|
||||
mapUpdated = 'map_updated',
|
||||
killsUpdated = 'kills_updated',
|
||||
detailedKillsUpdated = 'detailed_kills_updated',
|
||||
routes = 'routes',
|
||||
centerSystem = 'center_system',
|
||||
selectSystem = 'select_system',
|
||||
@@ -43,6 +44,7 @@ export type Command =
|
||||
| Commands.updateConnection
|
||||
| Commands.mapUpdated
|
||||
| Commands.killsUpdated
|
||||
| Commands.detailedKillsUpdated
|
||||
| Commands.routes
|
||||
| Commands.selectSystem
|
||||
| Commands.centerSystem
|
||||
@@ -76,9 +78,11 @@ export type CommandCharacterRemoved = CharacterTypeRaw;
|
||||
export type CommandCharacterUpdated = CharacterTypeRaw;
|
||||
export type CommandPresentCharacters = string[];
|
||||
export type CommandUpdateConnection = SolarSystemConnection;
|
||||
export type CommandSignaturesUpdated = string;
|
||||
export type CommandMapUpdated = Partial<CommandInit>;
|
||||
export type CommandRoutes = RoutesList;
|
||||
export type CommandKillsUpdated = Kill[];
|
||||
export type CommandDetailedKillsUpdated = Record<string, DetailedKill[]>;
|
||||
export type CommandSelectSystem = string | undefined;
|
||||
export type CommandCenterSystem = string | undefined;
|
||||
export type CommandLinkSignatureToSystem = {
|
||||
@@ -103,6 +107,7 @@ export interface CommandData {
|
||||
[Commands.mapUpdated]: CommandMapUpdated;
|
||||
[Commands.routes]: CommandRoutes;
|
||||
[Commands.killsUpdated]: CommandKillsUpdated;
|
||||
[Commands.detailedKillsUpdated]: CommandDetailedKillsUpdated;
|
||||
[Commands.selectSystem]: CommandSelectSystem;
|
||||
[Commands.centerSystem]: CommandCenterSystem;
|
||||
[Commands.linkSignatureToSystem]: CommandLinkSignatureToSystem;
|
||||
@@ -151,6 +156,8 @@ export enum OutCommand {
|
||||
linkSignatureToSystem = 'link_signature_to_system',
|
||||
getCorporationNames = 'get_corporation_names',
|
||||
getCorporationTicker = 'get_corporation_ticker',
|
||||
getSystemKills = 'get_system_kills',
|
||||
getSystemsKills = 'get_systems_kills',
|
||||
|
||||
// Only UI commands
|
||||
openSettings = 'open_settings',
|
||||
@@ -161,4 +168,5 @@ export enum OutCommand {
|
||||
searchSystems = 'search_systems',
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type OutCommandHandler = <T = any>(event: { type: OutCommand; data: any }) => Promise<T>;
|
||||
|
||||
@@ -26,15 +26,20 @@ export type GroupType = {
|
||||
h: number;
|
||||
};
|
||||
|
||||
export type SignatureCustomInfo = {
|
||||
k162Type?: string;
|
||||
isEOL?: boolean;
|
||||
};
|
||||
|
||||
export type SystemSignature = {
|
||||
eve_id: string;
|
||||
kind: SignatureKind;
|
||||
name: string;
|
||||
// SignatureCustomInfo
|
||||
custom_info?: string;
|
||||
description?: string;
|
||||
group: SignatureGroup;
|
||||
type: string;
|
||||
k162Type?: string;
|
||||
linked_system?: SolarSystemStaticInfoRaw;
|
||||
inserted_at?: string;
|
||||
updated_at?: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { XYPosition } from 'reactflow';
|
||||
|
||||
import { SystemSignature } from '@/hooks/Mapper/types/signatures';
|
||||
import { DetailedKill } from './kills';
|
||||
|
||||
export enum SolarSystemStaticInfoRawNames {
|
||||
regionId = 'region_id',
|
||||
@@ -117,6 +118,7 @@ export type SolarSystemRawType = {
|
||||
status: number;
|
||||
name: string | null;
|
||||
temporary_name: string | null;
|
||||
linked_sig_eve_id: string | null;
|
||||
|
||||
system_static_info: SolarSystemStaticInfoRaw;
|
||||
system_signatures: SystemSignature[];
|
||||
|
||||
BIN
assets/static/images/news/01-20-structure-widget/cover.png
Executable file
BIN
assets/static/images/news/01-20-structure-widget/cover.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
assets/static/images/news/01-20-structure-widget/enable-widget.png
Executable file
BIN
assets/static/images/news/01-20-structure-widget/enable-widget.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/static/images/news/01-27-zkill-widget/cover.png
Executable file
BIN
assets/static/images/news/01-27-zkill-widget/cover.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 409 KiB |
BIN
assets/static/images/news/01-27-zkill-widget/enable-zkill.png
Executable file
BIN
assets/static/images/news/01-27-zkill-widget/enable-zkill.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
@@ -53,6 +53,11 @@ public_api_disabled =
|
||||
|> get_var_from_path_or_env("WANDERER_PUBLIC_API_DISABLED", "false")
|
||||
|> String.to_existing_atom()
|
||||
|
||||
zkill_preload_disabled =
|
||||
config_dir
|
||||
|> get_var_from_path_or_env("WANDERER_ZKILL_PRELOAD_DISABLED", "false")
|
||||
|> String.to_existing_atom()
|
||||
|
||||
map_subscriptions_enabled =
|
||||
config_dir
|
||||
|> get_var_from_path_or_env("WANDERER_MAP_SUBSCRIPTIONS_ENABLED", "false")
|
||||
@@ -64,7 +69,19 @@ map_subscription_characters_limit =
|
||||
|
||||
map_subscription_hubs_limit =
|
||||
config_dir
|
||||
|> get_int_from_path_or_env("WANDERER_MAP_SUBSCRIPTION_HUBS_LIMIT", 100)
|
||||
|> get_int_from_path_or_env("WANDERER_MAP_SUBSCRIPTION_HUBS_LIMIT", 10)
|
||||
|
||||
map_subscription_base_price =
|
||||
config_dir
|
||||
|> get_int_from_path_or_env("WANDERER_MAP_SUBSCRIPTION_BASE_PRICE", 100_000_000)
|
||||
|
||||
map_subscription_extra_characters_100_price =
|
||||
config_dir
|
||||
|> get_int_from_path_or_env("WANDERER_MAP_SUBSCRIPTION_EXTRA_CHARACTERS_100_PRICE", 50_000_000)
|
||||
|
||||
map_subscription_extra_hubs_10_price =
|
||||
config_dir
|
||||
|> get_int_from_path_or_env("WANDERER_MAP_SUBSCRIPTION_EXTRA_HUBS_10_PRICE", 10_000_000)
|
||||
|
||||
map_connection_auto_expire_hours =
|
||||
config_dir
|
||||
@@ -76,7 +93,7 @@ map_connection_auto_eol_hours =
|
||||
|
||||
map_connection_eol_expire_timeout_mins =
|
||||
config_dir
|
||||
|> get_int_from_path_or_env("WANDERER_MAP_CONNECTION_EOL_EXPIRE_TIMEOUT_MINS", 30)
|
||||
|> get_int_from_path_or_env("WANDERER_MAP_CONNECTION_EOL_EXPIRE_TIMEOUT_MINS", 60)
|
||||
|
||||
wallet_tracking_enabled =
|
||||
config_dir
|
||||
@@ -101,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,
|
||||
@@ -117,16 +135,16 @@ config :wanderer_app,
|
||||
},
|
||||
%{
|
||||
id: "omega",
|
||||
characters_limit: 300,
|
||||
hubs_limit: 20,
|
||||
base_price: 250_000_000,
|
||||
characters_limit: map_subscription_characters_limit * 2,
|
||||
hubs_limit: map_subscription_hubs_limit * 2,
|
||||
base_price: map_subscription_base_price,
|
||||
month_3_discount: 0.2,
|
||||
month_6_discount: 0.4,
|
||||
month_12_discount: 0.5
|
||||
}
|
||||
],
|
||||
extra_characters_100: 75_000_000,
|
||||
extra_hubs_10: 25_000_000
|
||||
extra_characters_100: map_subscription_extra_characters_100_price,
|
||||
extra_hubs_10: map_subscription_extra_hubs_10_price
|
||||
}
|
||||
|
||||
config :ueberauth, Ueberauth,
|
||||
|
||||
@@ -127,7 +127,6 @@ defmodule WandererApp.Api.Map do
|
||||
update :update_api_key do
|
||||
accept [:public_api_key]
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
attributes do
|
||||
|
||||
@@ -95,7 +95,9 @@ defmodule WandererApp.Api.UserActivity do
|
||||
:map_acl_member_updated,
|
||||
:map_connection_added,
|
||||
:map_connection_updated,
|
||||
:map_connection_removed
|
||||
:map_connection_removed,
|
||||
:signatures_added,
|
||||
:signatures_removed
|
||||
]
|
||||
)
|
||||
|
||||
@@ -108,8 +110,6 @@ defmodule WandererApp.Api.UserActivity do
|
||||
update_timestamp(:updated_at)
|
||||
end
|
||||
|
||||
|
||||
|
||||
relationships do
|
||||
belongs_to :character, WandererApp.Api.Character do
|
||||
allow_nil? true
|
||||
|
||||
@@ -13,39 +13,48 @@ defmodule WandererApp.Application do
|
||||
WandererAppWeb.Telemetry,
|
||||
WandererApp.Vault,
|
||||
WandererApp.Repo,
|
||||
|
||||
{Phoenix.PubSub, name: WandererApp.PubSub, adapter_name: Phoenix.PubSub.PG2},
|
||||
{Finch, name: WandererApp.Finch},
|
||||
|
||||
{
|
||||
Finch,
|
||||
name: WandererApp.Finch,
|
||||
pools: %{
|
||||
default: [
|
||||
size: 25, # number of connections per pool
|
||||
count: 2, # number of pools (so total 50 connections)
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
WandererApp.Cache,
|
||||
Supervisor.child_spec({Cachex, name: :system_static_info_cache},
|
||||
id: :system_static_info_cache_worker
|
||||
),
|
||||
Supervisor.child_spec({Cachex, name: :ship_types_cache},
|
||||
id: :ship_types_cache_worker
|
||||
),
|
||||
Supervisor.child_spec({Cachex, name: :character_cache}, id: :character_cache_worker),
|
||||
Supervisor.child_spec({Cachex, name: :map_cache}, id: :map_cache_worker),
|
||||
Supervisor.child_spec({Cachex, name: :character_state_cache},
|
||||
id: :character_state_cache_worker
|
||||
),
|
||||
|
||||
Supervisor.child_spec({Cachex, name: :system_static_info_cache}, id: :system_static_info_cache_worker),
|
||||
Supervisor.child_spec({Cachex, name: :ship_types_cache}, id: :ship_types_cache_worker),
|
||||
Supervisor.child_spec({Cachex, name: :character_cache}, id: :character_cache_worker),
|
||||
Supervisor.child_spec({Cachex, name: :map_cache}, id: :map_cache_worker),
|
||||
Supervisor.child_spec({Cachex, name: :character_state_cache}, id: :character_state_cache_worker),
|
||||
|
||||
WandererApp.Scheduler,
|
||||
|
||||
{Registry, keys: :unique, name: WandererApp.MapRegistry},
|
||||
{Registry, keys: :unique, name: WandererApp.Character.TrackerRegistry},
|
||||
{PartitionSupervisor,
|
||||
child_spec: DynamicSupervisor, name: WandererApp.Map.DynamicSupervisors},
|
||||
{PartitionSupervisor,
|
||||
child_spec: DynamicSupervisor, name: WandererApp.Character.DynamicSupervisors},
|
||||
|
||||
{PartitionSupervisor, child_spec: DynamicSupervisor, name: WandererApp.Map.DynamicSupervisors},
|
||||
{PartitionSupervisor, child_spec: DynamicSupervisor, name: WandererApp.Character.DynamicSupervisors},
|
||||
|
||||
WandererApp.Zkb.Supervisor,
|
||||
WandererApp.Server.ServerStatusTracker,
|
||||
WandererApp.Server.TheraDataFetcher,
|
||||
WandererApp.Character.TrackerManager,
|
||||
WandererApp.Map.Manager,
|
||||
WandererApp.Map.ZkbDataFetcher,
|
||||
|
||||
WandererAppWeb.Presence,
|
||||
WandererAppWeb.Endpoint
|
||||
] ++ maybe_start_corp_wallet_tracker(WandererApp.Env.map_subscriptions_enabled?())
|
||||
]
|
||||
++ maybe_start_corp_wallet_tracker(WandererApp.Env.map_subscriptions_enabled?())
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
opts = [strategy: :one_for_one, name: WandererApp.Supervisor]
|
||||
|
||||
Supervisor.start_link(children, opts)
|
||||
@@ -59,8 +68,6 @@ defmodule WandererApp.Application do
|
||||
end
|
||||
end
|
||||
|
||||
# Tell Phoenix to update the endpoint configuration
|
||||
# whenever the application is updated.
|
||||
@impl true
|
||||
def config_change(changed, _new, removed) do
|
||||
WandererAppWeb.Endpoint.config_change(changed, removed)
|
||||
@@ -72,5 +79,6 @@ defmodule WandererApp.Application do
|
||||
WandererApp.StartCorpWalletTrackerTask
|
||||
]
|
||||
|
||||
defp maybe_start_corp_wallet_tracker(_), do: []
|
||||
defp maybe_start_corp_wallet_tracker(_),
|
||||
do: []
|
||||
end
|
||||
|
||||
@@ -71,7 +71,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
{:ok, %{eve_id: eve_id}} = WandererApp.Character.get_character(character_id)
|
||||
|
||||
case WandererApp.Esi.get_character_info(eve_id) do
|
||||
{:ok, info} ->
|
||||
{:ok, _info} ->
|
||||
{:ok, character_state} = WandererApp.Character.get_character_state(character_id)
|
||||
|
||||
update = maybe_update_corporation(character_state, eve_id |> String.to_integer())
|
||||
|
||||
@@ -11,6 +11,7 @@ defmodule WandererApp.Env do
|
||||
def invites, do: get_key(:invites, false)
|
||||
def map_subscriptions_enabled?, do: get_key(:map_subscriptions_enabled, false)
|
||||
def public_api_disabled?, do: get_key(:public_api_disabled, false)
|
||||
def zkill_preload_disabled?, do: get_key(:zkill_preload_disabled, false)
|
||||
def wallet_tracking_enabled?, do: get_key(:wallet_tracking_enabled, false)
|
||||
def admins, do: get_key(:admins, [])
|
||||
def admin_username, do: get_key(:admin_username)
|
||||
@@ -39,4 +40,12 @@ defmodule WandererApp.Env do
|
||||
do: get_key(:map_connection_eol_expire_timeout_mins)
|
||||
|
||||
def get_key(key, default \\ nil), do: Application.get_env(@app, key, default)
|
||||
|
||||
@doc """
|
||||
A single map containing environment variables
|
||||
made available to react
|
||||
"""
|
||||
def to_client_env do
|
||||
%{detailedKillsDisabled: zkill_preload_disabled?()}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -24,6 +24,9 @@ defmodule WandererApp.Esi do
|
||||
defdelegate find_routes(map_id, origin, hubs, routes_settings), to: WandererApp.Esi.ApiClient
|
||||
defdelegate search(character_eve_id, opts \\ []), to: WandererApp.Esi.ApiClient
|
||||
|
||||
defdelegate get_killmail(killmail_id, killmail_hash, opts \\ []), to: WandererApp.Esi.ApiClient
|
||||
|
||||
|
||||
defdelegate set_autopilot_waypoint(
|
||||
add_to_beginning,
|
||||
clear_other_waypoints,
|
||||
|
||||
@@ -289,6 +289,16 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@decorate cacheable(
|
||||
cache: Cache,
|
||||
key: "killmail-#{killmail_id}-#{killmail_hash}",
|
||||
opts: [ttl: @ttl]
|
||||
)
|
||||
def get_killmail(killmail_id, killmail_hash, opts \\ []) do
|
||||
get("/killmails/#{killmail_id}/#{killmail_hash}/", _with_cache_opts(opts))
|
||||
end
|
||||
|
||||
@decorate cacheable(
|
||||
cache: Cache,
|
||||
key: "info-#{eve_id}",
|
||||
|
||||
@@ -68,7 +68,7 @@ defmodule WandererApp.Map do
|
||||
end
|
||||
|
||||
def get_characters_limit(map_id),
|
||||
do: {:ok, map_id |> get_map!() |> Map.get(:characters_limit, 100)}
|
||||
do: {:ok, map_id |> get_map!() |> Map.get(:characters_limit, 50)}
|
||||
|
||||
def is_subscription_active?(map_id) do
|
||||
{:ok, %{plan: plan}} = WandererApp.Map.SubscriptionManager.get_active_map_subscription(map_id)
|
||||
|
||||
@@ -6,114 +6,205 @@ defmodule WandererApp.Map.ZkbDataFetcher do
|
||||
|
||||
require Logger
|
||||
|
||||
alias WandererApp.Zkb.KillsProvider.KillsCache
|
||||
|
||||
@interval :timer.seconds(15)
|
||||
@store_map_kills_timeout :timer.hours(1)
|
||||
@logger Application.compile_env(:wanderer_app, :logger)
|
||||
@pubsub_client Application.compile_env(:wanderer_app, :pubsub_client)
|
||||
|
||||
# This means 120 “ticks” of 15s each → ~30 minutes
|
||||
@preload_cycle_ticks 120
|
||||
|
||||
def start_link(_) do
|
||||
GenServer.start(__MODULE__, [], name: __MODULE__)
|
||||
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init([]) do
|
||||
{:ok, timer} = :timer.send_interval(@interval, :fetch_data)
|
||||
|
||||
{:ok, %{timer: timer}}
|
||||
def init(_arg) do
|
||||
{:ok, _timer_ref} = :timer.send_interval(@interval, :fetch_data)
|
||||
{:ok, %{iteration: 0}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:fetch_data, state) do
|
||||
def handle_info(:fetch_data, %{iteration: iteration} = state) do
|
||||
WandererApp.Map.RegistryHelper.list_all_maps()
|
||||
|> Task.async_stream(
|
||||
fn %{id: map_id, pid: _server_pid} ->
|
||||
try do
|
||||
map_id
|
||||
|> WandererApp.Map.Server.map_pid()
|
||||
|> case do
|
||||
pid when is_pid(pid) ->
|
||||
_update_map_kills(map_id)
|
||||
if WandererApp.Map.Server.map_pid(map_id) do
|
||||
update_map_kills(map_id)
|
||||
|
||||
nil ->
|
||||
:ok
|
||||
unless WandererApp.Env.zkill_preload_disabled?() do
|
||||
update_detailed_map_kills(map_id)
|
||||
end
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
@logger.error(Exception.message(e))
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
max_concurrency: 10,
|
||||
on_timeout: :kill_task
|
||||
)
|
||||
|> Enum.map(fn _ -> :ok end)
|
||||
|> Enum.each(fn _ -> :ok end)
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
new_iteration = iteration + 1
|
||||
|
||||
@impl true
|
||||
def handle_info({ref, result}, state) do
|
||||
Process.demonitor(ref, [:flush])
|
||||
cond do
|
||||
WandererApp.Env.zkill_preload_disabled?() ->
|
||||
# If preload is disabled, just update iteration
|
||||
{:noreply, %{state | iteration: new_iteration}}
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
new_iteration >= @preload_cycle_ticks ->
|
||||
Logger.info("[ZkbDataFetcher] Triggering a fresh kill preload pass ...")
|
||||
WandererApp.Zkb.KillsPreloader.run_preload_now()
|
||||
{:noreply, %{state | iteration: 0}}
|
||||
|
||||
defp _update_map_kills(map_id) do
|
||||
case WandererApp.Cache.lookup!("map_#{map_id}:started", false) do
|
||||
true ->
|
||||
map_id
|
||||
|> WandererApp.Map.get_map!()
|
||||
|> Map.get(:systems, Map.new())
|
||||
|> Enum.reduce(Map.new(), fn {solar_system_id, _system}, acc ->
|
||||
kills_count = WandererApp.Cache.get("zkb_kills_#{solar_system_id}")
|
||||
acc |> Map.put(solar_system_id, kills_count || 0)
|
||||
end)
|
||||
|> _maybe_broadcast_map_kills(map_id)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
{:noreply, %{state | iteration: new_iteration}}
|
||||
end
|
||||
end
|
||||
|
||||
defp _maybe_broadcast_map_kills(new_kills_map, map_id) do
|
||||
{:ok, old_kills_map} = WandererApp.Cache.lookup("map_#{map_id}:zkb_kills", Map.new())
|
||||
# Catch any async task results we aren't explicitly pattern-matching
|
||||
@impl true
|
||||
def handle_info({ref, _result}, state) do
|
||||
Process.demonitor(ref, [:flush])
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
updated_kills_system_ids =
|
||||
new_kills_map
|
||||
|> Map.filter(fn {solar_system_id, new_kills_count} ->
|
||||
old_kills_count = old_kills_map |> Map.get(solar_system_id, 0)
|
||||
|
||||
new_kills_count != old_kills_count and
|
||||
new_kills_count > 0
|
||||
defp update_map_kills(map_id) do
|
||||
with_started_map(map_id, "basic kills update", fn ->
|
||||
map_id
|
||||
|> WandererApp.Map.get_map!()
|
||||
|> Map.get(:systems, %{})
|
||||
|> Enum.into(%{}, fn {solar_system_id, _system} ->
|
||||
kills_count = WandererApp.Cache.get("zkb_kills_#{solar_system_id}") || 0
|
||||
{solar_system_id, kills_count}
|
||||
end)
|
||||
|> Map.keys()
|
||||
|> maybe_broadcast_map_kills(map_id)
|
||||
end)
|
||||
end
|
||||
|
||||
removed_kills_system_ids =
|
||||
old_kills_map
|
||||
|> Map.filter(fn {solar_system_id, old_kills_count} ->
|
||||
new_kills_count = new_kills_map |> Map.get(solar_system_id, 0)
|
||||
defp update_detailed_map_kills(map_id) do
|
||||
with_started_map(map_id, "detailed kills update", fn ->
|
||||
systems =
|
||||
map_id
|
||||
|> WandererApp.Map.get_map!()
|
||||
|> Map.get(:systems, %{})
|
||||
|
||||
old_kills_count > 0 and new_kills_count == 0
|
||||
end)
|
||||
|> Map.keys()
|
||||
# Old cache data
|
||||
old_ids_map = WandererApp.Cache.get("map_#{map_id}:zkb_ids") || %{}
|
||||
old_details_map = WandererApp.Cache.get("map_#{map_id}:zkb_detailed_kills") || %{}
|
||||
|
||||
(updated_kills_system_ids ++ removed_kills_system_ids)
|
||||
|> case do
|
||||
[] ->
|
||||
new_ids_map =
|
||||
Enum.into(systems, %{}, fn {solar_system_id, _} ->
|
||||
ids = KillsCache.get_system_killmail_ids(solar_system_id) |> MapSet.new()
|
||||
{solar_system_id, ids}
|
||||
end)
|
||||
|
||||
changed_systems =
|
||||
new_ids_map
|
||||
|> Enum.filter(fn {system_id, new_ids_set} ->
|
||||
old_set = MapSet.new(Map.get(old_ids_map, system_id, []))
|
||||
not MapSet.equal?(new_ids_set, old_set)
|
||||
end)
|
||||
|> Enum.map(&elem(&1, 0))
|
||||
|
||||
if changed_systems == [] do
|
||||
Logger.debug("[ZkbDataFetcher] No changes in detailed kills for map_id=#{map_id}")
|
||||
:ok
|
||||
else
|
||||
# Build new details for each changed system
|
||||
updated_details_map =
|
||||
Enum.reduce(changed_systems, old_details_map, fn system_id, acc ->
|
||||
kill_ids =
|
||||
new_ids_map
|
||||
|> Map.fetch!(system_id)
|
||||
|> MapSet.to_list()
|
||||
|
||||
system_ids ->
|
||||
:ok =
|
||||
WandererApp.Cache.put("map_#{map_id}:zkb_kills", new_kills_map,
|
||||
ttl: @store_map_kills_timeout
|
||||
)
|
||||
kill_details =
|
||||
kill_ids
|
||||
|> Enum.map(&KillsCache.get_killmail/1)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
Map.put(acc, system_id, kill_details)
|
||||
end)
|
||||
|
||||
updated_ids_map =
|
||||
Enum.reduce(changed_systems, old_ids_map, fn system_id, acc ->
|
||||
new_ids_list = new_ids_map[system_id] |> MapSet.to_list()
|
||||
Map.put(acc, system_id, new_ids_list)
|
||||
end)
|
||||
|
||||
WandererApp.Cache.put("map_#{map_id}:zkb_ids", updated_ids_map,
|
||||
ttl: :timer.hours(KillsCache.killmail_ttl)
|
||||
)
|
||||
|
||||
WandererApp.Cache.put("map_#{map_id}:zkb_detailed_kills", updated_details_map,
|
||||
ttl: :timer.hours(KillsCache.killmail_ttl)
|
||||
)
|
||||
|
||||
changed_data = Map.take(updated_details_map, changed_systems)
|
||||
|
||||
@pubsub_client.broadcast!(WandererApp.PubSub, map_id, %{
|
||||
event: :kills_updated,
|
||||
payload: new_kills_map |> Map.take(system_ids)
|
||||
event: :detailed_kills_updated,
|
||||
payload: changed_data
|
||||
})
|
||||
|
||||
:ok
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp maybe_broadcast_map_kills(new_kills_map, map_id) do
|
||||
{:ok, old_kills_map} = WandererApp.Cache.lookup("map_#{map_id}:zkb_kills", %{})
|
||||
|
||||
updated_kills_system_ids =
|
||||
old_kills_map
|
||||
|> Map.keys()
|
||||
|> Enum.filter(fn system_id ->
|
||||
new_kills_count = Map.get(new_kills_map, system_id, 0)
|
||||
old_kills_count = Map.get(old_kills_map, system_id, 0)
|
||||
new_kills_count != old_kills_count and new_kills_count > 0
|
||||
end)
|
||||
|
||||
removed_kills_system_ids =
|
||||
old_kills_map
|
||||
|> Map.keys()
|
||||
|> Enum.filter(fn system_id ->
|
||||
old_kills_count = Map.get(old_kills_map, system_id, 0)
|
||||
new_kills_count = Map.get(new_kills_map, system_id, 0)
|
||||
old_kills_count > 0 and new_kills_count == 0
|
||||
end)
|
||||
|
||||
changed_system_ids = updated_kills_system_ids ++ removed_kills_system_ids
|
||||
|
||||
if changed_system_ids == [] do
|
||||
:ok
|
||||
else
|
||||
:ok =
|
||||
WandererApp.Cache.put("map_#{map_id}:zkb_kills", new_kills_map,
|
||||
ttl: @store_map_kills_timeout
|
||||
)
|
||||
|
||||
payload = Map.take(new_kills_map, changed_system_ids)
|
||||
|
||||
@pubsub_client.broadcast!(WandererApp.PubSub, map_id, %{
|
||||
event: :kills_updated,
|
||||
payload: payload
|
||||
})
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp with_started_map(map_id, label \\ "operation", fun) when is_function(fun, 0) do
|
||||
if WandererApp.Cache.lookup!("map_#{map_id}:started", false) do
|
||||
fun.()
|
||||
else
|
||||
Logger.debug("[ZkbDataFetcher] Map #{map_id} not started => skipping #{label}")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -206,8 +206,24 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|
||||
user_id,
|
||||
character_id
|
||||
) do
|
||||
connections_to_remove =
|
||||
filtered_ids =
|
||||
removed_ids
|
||||
|> Enum.map(fn solar_system_id ->
|
||||
WandererApp.Map.find_system_by_location(map_id, %{solar_system_id: solar_system_id})
|
||||
end)
|
||||
|> Enum.filter(fn system -> not is_nil(system) && not system.locked end)
|
||||
|> Enum.map(&{&1.solar_system_id, &1.id})
|
||||
|
||||
solar_system_ids_to_remove =
|
||||
filtered_ids
|
||||
|> Enum.map(fn {solar_system_id, _} -> solar_system_id end)
|
||||
|
||||
system_ids_to_remove =
|
||||
filtered_ids
|
||||
|> Enum.map(fn {_, system_id} -> system_id end)
|
||||
|
||||
connections_to_remove =
|
||||
solar_system_ids_to_remove
|
||||
|> Enum.map(fn solar_system_id ->
|
||||
WandererApp.Map.find_connections(map_id, solar_system_id)
|
||||
end)
|
||||
@@ -215,9 +231,9 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|
||||
|> Enum.uniq_by(& &1.id)
|
||||
|
||||
:ok = WandererApp.Map.remove_connections(map_id, connections_to_remove)
|
||||
:ok = WandererApp.Map.remove_systems(map_id, removed_ids)
|
||||
:ok = WandererApp.Map.remove_systems(map_id, solar_system_ids_to_remove)
|
||||
|
||||
removed_ids
|
||||
solar_system_ids_to_remove
|
||||
|> Enum.each(fn solar_system_id ->
|
||||
map_id
|
||||
|> WandererApp.MapSystemRepo.remove_from_map(solar_system_id)
|
||||
@@ -237,7 +253,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|
||||
WandererApp.MapConnectionRepo.destroy(map_id, connection)
|
||||
end)
|
||||
|
||||
removed_ids
|
||||
solar_system_ids_to_remove
|
||||
|> Enum.map(fn solar_system_id ->
|
||||
WandererApp.Api.MapSystemSignature.by_linked_system_id!(solar_system_id)
|
||||
end)
|
||||
@@ -250,10 +266,28 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|
||||
Impl.broadcast!(map_id, :signatures_updated, system.solar_system_id)
|
||||
end)
|
||||
|
||||
@ddrt.delete(removed_ids, rtree_name)
|
||||
linked_system_ids =
|
||||
system_ids_to_remove
|
||||
|> Enum.map(fn system_id ->
|
||||
WandererApp.Api.MapSystemSignature.by_system_id!(system_id)
|
||||
|> Enum.filter(fn s -> not is_nil(s.linked_system_id) end)
|
||||
|> Enum.map(fn s -> s.linked_system_id end)
|
||||
end)
|
||||
|> List.flatten()
|
||||
|> Enum.uniq()
|
||||
|
||||
linked_system_ids
|
||||
|> Enum.each(fn linked_system_id ->
|
||||
WandererApp.Map.Server.update_system_linked_sig_eve_id(map_id, %{
|
||||
solar_system_id: linked_system_id,
|
||||
linked_sig_eve_id: nil
|
||||
})
|
||||
end)
|
||||
|
||||
@ddrt.delete(solar_system_ids_to_remove, rtree_name)
|
||||
|
||||
Impl.broadcast!(map_id, :remove_connections, connections_to_remove)
|
||||
Impl.broadcast!(map_id, :systems_removed, removed_ids)
|
||||
Impl.broadcast!(map_id, :systems_removed, solar_system_ids_to_remove)
|
||||
|
||||
case not is_nil(user_id) do
|
||||
true ->
|
||||
@@ -262,12 +296,12 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|
||||
character_id: character_id,
|
||||
user_id: user_id,
|
||||
map_id: map_id,
|
||||
solar_system_ids: removed_ids
|
||||
solar_system_ids: solar_system_ids_to_remove
|
||||
})
|
||||
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :map, :systems, :remove],
|
||||
%{count: removed_ids |> Enum.count()}
|
||||
%{count: solar_system_ids_to_remove |> Enum.count()}
|
||||
)
|
||||
|
||||
:ok
|
||||
|
||||
@@ -55,18 +55,33 @@ defmodule WandererApp.Maps do
|
||||
|
||||
def get_available_maps(current_user) do
|
||||
case WandererApp.Api.Map.available(%{}, actor: current_user) do
|
||||
{:ok, maps} -> {:ok, maps |> _filter_blocked_maps(current_user)}
|
||||
{:ok, maps} -> {:ok, maps |> filter_blocked_maps(current_user)}
|
||||
_ -> {:ok, []}
|
||||
end
|
||||
end
|
||||
|
||||
def get_tracked_map_characters(map_id, current_user) do
|
||||
case WandererApp.MapCharacterSettingsRepo.get_tracked_by_map_filtered(
|
||||
map_id,
|
||||
current_user.characters |> Enum.map(& &1.id)
|
||||
) do
|
||||
{:ok, settings} ->
|
||||
{:ok,
|
||||
settings
|
||||
|> Enum.map(fn s -> s |> Ash.load!(:character) |> Map.get(:character) end)}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end
|
||||
|
||||
def load_characters(map, character_settings, user_id) do
|
||||
{:ok, user_characters} =
|
||||
WandererApp.Api.Character.active_by_user(%{user_id: user_id})
|
||||
|
||||
characters =
|
||||
map
|
||||
|> _get_map_available_characters(user_characters)
|
||||
|> get_map_available_characters(user_characters)
|
||||
|> Enum.map(fn c ->
|
||||
map_character(c, character_settings |> Enum.find(&(&1.character_id == c.id)))
|
||||
end)
|
||||
@@ -146,7 +161,7 @@ defmodule WandererApp.Maps do
|
||||
}}
|
||||
end
|
||||
|
||||
defp _get_map_available_characters(map, user_characters) do
|
||||
defp get_map_available_characters(map, user_characters) do
|
||||
{:ok,
|
||||
%{
|
||||
map_acl_owner_ids: map_acl_owner_ids,
|
||||
@@ -164,7 +179,7 @@ defmodule WandererApp.Maps do
|
||||
end)
|
||||
end
|
||||
|
||||
defp _filter_blocked_maps(maps, current_user) do
|
||||
defp filter_blocked_maps(maps, current_user) do
|
||||
user_character_ids = current_user.characters |> Enum.map(& &1.id)
|
||||
user_character_eve_ids = current_user.characters |> Enum.map(& &1.eve_id)
|
||||
|
||||
|
||||
275
lib/wanderer_app/zkb/zkb_kills_preloader.ex
Normal file
275
lib/wanderer_app/zkb/zkb_kills_preloader.ex
Normal file
@@ -0,0 +1,275 @@
|
||||
defmodule WandererApp.Zkb.KillsPreloader do
|
||||
@moduledoc """
|
||||
On startup, kicks off two passes (quick and expanded) to preload kills data.
|
||||
|
||||
There is also a `run_preload_now/0` function for manual triggering of the same logic.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias WandererApp.Zkb.KillsProvider
|
||||
alias WandererApp.Zkb.KillsProvider.KillsCache
|
||||
|
||||
# ----------------
|
||||
# Configuration
|
||||
# ----------------
|
||||
|
||||
# (1) Quick pass
|
||||
@quick_limit 1
|
||||
@quick_hours 1
|
||||
|
||||
# (2) Expanded pass
|
||||
@expanded_limit 25
|
||||
@expanded_hours 24
|
||||
|
||||
# How many minutes back we look for “last active” maps
|
||||
@last_active_cutoff 30
|
||||
|
||||
# Default concurrency if not provided
|
||||
@default_max_concurrency 2
|
||||
|
||||
@doc """
|
||||
Starts the GenServer with optional opts (like `max_concurrency`).
|
||||
"""
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Public helper to explicitly request a fresh preload pass (both quick & expanded).
|
||||
"""
|
||||
def run_preload_now() do
|
||||
send(__MODULE__, :start_preload)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
state = %{
|
||||
phase: :idle,
|
||||
calls_count: 0,
|
||||
max_concurrency: Keyword.get(opts, :max_concurrency, @default_max_concurrency)
|
||||
}
|
||||
|
||||
# Kick off the preload passes once at startup
|
||||
send(self(), :start_preload)
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:start_preload, state) do
|
||||
# Gather last-active maps (or fallback).
|
||||
cutoff_time =
|
||||
DateTime.utc_now()
|
||||
|> DateTime.add(-@last_active_cutoff, :minute)
|
||||
|
||||
last_active_maps_result = WandererApp.Api.MapState.get_last_active(cutoff_time)
|
||||
last_active_maps = resolve_last_active_maps(last_active_maps_result)
|
||||
|
||||
# Gather systems from those maps
|
||||
system_tuples = gather_visible_systems(last_active_maps)
|
||||
unique_systems = Enum.uniq(system_tuples)
|
||||
|
||||
Logger.debug("""
|
||||
[KillsPreloader] Found #{length(unique_systems)} unique systems \
|
||||
across #{length(last_active_maps)} map(s)
|
||||
""")
|
||||
|
||||
# ---- QUICK PASS ----
|
||||
state_quick = %{state | phase: :quick_pass}
|
||||
|
||||
{time_quick_ms, state_after_quick} =
|
||||
measure_execution_time(fn ->
|
||||
do_pass(unique_systems, :quick, @quick_hours, @quick_limit, state_quick)
|
||||
end)
|
||||
|
||||
Logger.info("[KillsPreloader] Phase 1 (quick) done => calls_count=#{state_after_quick.calls_count}, elapsed=#{time_quick_ms}ms")
|
||||
|
||||
# ---- EXPANDED PASS ----
|
||||
state_expanded = %{state_after_quick | phase: :expanded_pass}
|
||||
|
||||
{time_expanded_ms, final_state} =
|
||||
measure_execution_time(fn ->
|
||||
do_pass(unique_systems, :expanded, @quick_hours, @expanded_limit, state_expanded)
|
||||
end)
|
||||
|
||||
Logger.info("[KillsPreloader] Phase 2 (expanded) done => calls_count=#{final_state.calls_count}, elapsed=#{time_expanded_ms}ms")
|
||||
|
||||
# Reset phase to :idle
|
||||
{:noreply, %{final_state | phase: :idle}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(_other, state), do: {:noreply, state}
|
||||
|
||||
defp resolve_last_active_maps({:ok, []}) do
|
||||
Logger.warning("[KillsPreloader] No last-active maps found. Using fallback logic...")
|
||||
|
||||
case WandererApp.Maps.get_available_maps() do
|
||||
{:ok, []} ->
|
||||
Logger.error("[KillsPreloader] Fallback: get_available_maps returned zero maps!")
|
||||
[]
|
||||
|
||||
{:ok, maps} ->
|
||||
# pick the newest map by updated_at
|
||||
fallback_map = Enum.max_by(maps, & &1.updated_at, fn -> nil end)
|
||||
if fallback_map, do: [fallback_map], else: []
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_last_active_maps({:ok, maps}) when is_list(maps),
|
||||
do: maps
|
||||
|
||||
defp resolve_last_active_maps({:error, reason}) do
|
||||
Logger.error("[KillsPreloader] Could not load last-active maps => #{inspect(reason)}")
|
||||
[]
|
||||
end
|
||||
|
||||
|
||||
defp gather_visible_systems(maps) do
|
||||
maps
|
||||
|> Enum.flat_map(fn map_record ->
|
||||
the_map_id = Map.get(map_record, :map_id) || Map.get(map_record, :id)
|
||||
|
||||
case WandererApp.MapSystemRepo.get_visible_by_map(the_map_id) do
|
||||
{:ok, systems} ->
|
||||
Enum.map(systems, fn sys -> {the_map_id, sys.solar_system_id} end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("[KillsPreloader] get_visible_by_map failed => map_id=#{inspect(the_map_id)}, reason=#{inspect(reason)}")
|
||||
[]
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
|
||||
defp do_pass(unique_systems, pass_type, hours, limit, state) do
|
||||
Logger.info("[KillsPreloader] Starting #{pass_type} pass => #{length(unique_systems)} systems")
|
||||
|
||||
{final_state, kills_map} =
|
||||
unique_systems
|
||||
|> Task.async_stream(
|
||||
fn {_map_id, system_id} ->
|
||||
fetch_kills_for_system(system_id, pass_type, hours, limit, state)
|
||||
end,
|
||||
max_concurrency: state.max_concurrency,
|
||||
timeout: pass_timeout_ms(pass_type)
|
||||
)
|
||||
|> Enum.reduce({state, %{}}, fn task_result, {acc_state, acc_map} ->
|
||||
reduce_task_result(pass_type, task_result, acc_state, acc_map)
|
||||
end)
|
||||
|
||||
if map_size(kills_map) > 0 do
|
||||
broadcast_all_kills(kills_map, pass_type)
|
||||
end
|
||||
|
||||
final_state
|
||||
end
|
||||
|
||||
defp fetch_kills_for_system(system_id, :quick, hours, limit, state) do
|
||||
Logger.debug("[KillsPreloader] Quick fetch => system=#{system_id}, hours=#{hours}, limit=#{limit}")
|
||||
|
||||
case KillsProvider.Fetcher.fetch_kills_for_system(system_id, hours, state, limit: limit, force: false) do
|
||||
{:ok, kills, updated_state} ->
|
||||
{:ok, system_id, kills, updated_state}
|
||||
|
||||
{:error, reason, updated_state} ->
|
||||
Logger.warning("[KillsPreloader] Quick fetch failed => system=#{system_id}, reason=#{inspect(reason)}")
|
||||
{:error, reason, updated_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_kills_for_system(system_id, :expanded, hours, limit, state) do
|
||||
Logger.debug("[KillsPreloader] Expanded fetch => system=#{system_id}, hours=#{hours}, limit=#{limit} (forcing refresh)")
|
||||
|
||||
with {:ok, kills_1h, updated_state} <-
|
||||
KillsProvider.Fetcher.fetch_kills_for_system(system_id, hours, state, limit: limit, force: true),
|
||||
{:ok, final_kills, final_state} <-
|
||||
maybe_fetch_more_if_needed(system_id, kills_1h, limit, updated_state) do
|
||||
{:ok, system_id, final_kills, final_state}
|
||||
else
|
||||
{:error, reason, updated_state} ->
|
||||
Logger.warning("[KillsPreloader] Expanded fetch (#{hours}h) failed => system=#{system_id}, reason=#{inspect(reason)}")
|
||||
{:error, reason, updated_state}
|
||||
end
|
||||
end
|
||||
|
||||
# If we got fewer kills than `limit` from the 1h fetch, top up from 24h
|
||||
defp maybe_fetch_more_if_needed(system_id, kills_1h, limit, state) do
|
||||
if length(kills_1h) < limit do
|
||||
needed = limit - length(kills_1h)
|
||||
Logger.debug("[KillsPreloader] Expanding to #{@expanded_hours}h => system=#{system_id}, need=#{needed} more kills")
|
||||
|
||||
case KillsProvider.Fetcher.fetch_kills_for_system(system_id, @expanded_hours, state, limit: needed, force: true) do
|
||||
{:ok, _kills_24h, updated_state2} ->
|
||||
final_kills =
|
||||
KillsCache.fetch_cached_kills(system_id)
|
||||
|> Enum.take(limit)
|
||||
|
||||
{:ok, final_kills, updated_state2}
|
||||
|
||||
{:error, reason2, updated_state2} ->
|
||||
Logger.warning("[KillsPreloader] #{@expanded_hours}h fetch failed => system=#{system_id}, reason=#{inspect(reason2)}")
|
||||
{:error, reason2, updated_state2}
|
||||
end
|
||||
else
|
||||
{:ok, kills_1h, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp reduce_task_result(pass_type, task_result, acc_state, acc_map) do
|
||||
case task_result do
|
||||
{:ok, {:ok, sys_id, kills, updated_state}} ->
|
||||
# Merge calls count from updated_state into acc_state
|
||||
new_state = merge_calls_count(acc_state, updated_state)
|
||||
new_map = Map.put(acc_map, sys_id, kills)
|
||||
{new_state, new_map}
|
||||
|
||||
{:ok, {:error, reason, updated_state}} ->
|
||||
log_failed_task(pass_type, reason)
|
||||
new_state = merge_calls_count(acc_state, updated_state)
|
||||
{new_state, acc_map}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("[KillsPreloader] #{pass_type} fetch task crashed => #{inspect(reason)}")
|
||||
{acc_state, acc_map}
|
||||
end
|
||||
end
|
||||
|
||||
defp log_failed_task(:quick, reason),
|
||||
do: Logger.warning("[KillsPreloader] Quick fetch task failed => #{inspect(reason)}")
|
||||
|
||||
defp log_failed_task(:expanded, reason),
|
||||
do: Logger.error("[KillsPreloader] Expanded fetch task failed => #{inspect(reason)}")
|
||||
|
||||
defp broadcast_all_kills(kills_map, pass_type) do
|
||||
Logger.info("[KillsPreloader] Broadcasting kills => #{map_size(kills_map)} systems (#{pass_type})")
|
||||
|
||||
Phoenix.PubSub.broadcast!(
|
||||
WandererApp.PubSub,
|
||||
"zkb_preload",
|
||||
%{
|
||||
event: :detailed_kills_updated,
|
||||
payload: kills_map,
|
||||
fetch_type: pass_type
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
defp merge_calls_count(%{calls_count: c1} = st1, %{calls_count: c2}),
|
||||
do: %{st1 | calls_count: c1 + c2}
|
||||
|
||||
defp merge_calls_count(st1, _other),
|
||||
do: st1
|
||||
|
||||
defp pass_timeout_ms(:quick), do: :timer.minutes(2)
|
||||
defp pass_timeout_ms(:expanded), do: :timer.minutes(5)
|
||||
|
||||
defp measure_execution_time(fun) when is_function(fun, 0) do
|
||||
start = System.monotonic_time()
|
||||
result = fun.()
|
||||
finish = System.monotonic_time()
|
||||
ms = System.convert_time_unit(finish - start, :native, :millisecond)
|
||||
{ms, result}
|
||||
end
|
||||
end
|
||||
@@ -1,127 +1,29 @@
|
||||
defmodule WandererApp.Zkb.KillsProvider do
|
||||
@moduledoc false
|
||||
use Fresh
|
||||
require Logger
|
||||
|
||||
alias WandererApp.Zkb.KillsProvider.Websocket
|
||||
|
||||
defstruct [:connected]
|
||||
|
||||
require Logger
|
||||
def handle_connect(status, headers, state),
|
||||
do: Websocket.handle_connect(status, headers, state)
|
||||
|
||||
@heartbeat_interval 1_000
|
||||
def handle_in(frame, state),
|
||||
do: Websocket.handle_in(frame, state)
|
||||
|
||||
def handle_connect(_status, _headers, state) do
|
||||
Logger.debug(fn ->
|
||||
"#{__MODULE__}: connected to kills stream"
|
||||
end)
|
||||
def handle_control(msg, state),
|
||||
do: Websocket.handle_control(msg, state)
|
||||
|
||||
handle_subscribe("killstream", %__MODULE__{state | connected: true})
|
||||
end
|
||||
def handle_info(msg, state),
|
||||
do: Websocket.handle_info(msg, state)
|
||||
|
||||
def handle_in({:text, frame}, state) do
|
||||
frame
|
||||
|> Jason.decode!()
|
||||
|> handle_websocket(state)
|
||||
end
|
||||
def handle_disconnect(code, reason, state),
|
||||
do: Websocket.handle_disconnect(code, reason, state)
|
||||
|
||||
def handle_control({:ping, _message}, state) do
|
||||
Process.send_after(self(), :heartbeat, @heartbeat_interval)
|
||||
{:ok, state}
|
||||
end
|
||||
def handle_error(err, state),
|
||||
do: Websocket.handle_error(err, state)
|
||||
|
||||
def handle_control(_event, state) do
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def handle_info(:heartbeat, state) do
|
||||
payload =
|
||||
Jason.encode!(%{
|
||||
"action" => "pong"
|
||||
})
|
||||
|
||||
{:reply, {:text, payload}, state}
|
||||
end
|
||||
|
||||
def handle_info(_message, state) do
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def handle_info(_message, _ws, state) do
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
defp handle_subscribe(channel, state) do
|
||||
Logger.debug(fn ->
|
||||
"#{__MODULE__} subscribe: #{inspect(channel, pretty: true)}"
|
||||
end)
|
||||
|
||||
payload =
|
||||
Jason.encode!(%{
|
||||
"action" => "sub",
|
||||
"channel" => channel
|
||||
})
|
||||
|
||||
{:reply, {:text, payload}, state}
|
||||
end
|
||||
|
||||
defp handle_websocket(message, state) do
|
||||
case message |> parse_message() do
|
||||
nil ->
|
||||
{:ok, state}
|
||||
|
||||
%{solar_system_id: solar_system_id, kill_time: kill_time} = _message ->
|
||||
case DateTime.diff(DateTime.utc_now(), kill_time, :hour) do
|
||||
0 ->
|
||||
WandererApp.Cache.incr("zkb_kills_#{solar_system_id}", 1,
|
||||
default: 0,
|
||||
ttl: :timer.hours(1)
|
||||
)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def handle_disconnect(1002, reason, _state) do
|
||||
Logger.warning(fn ->
|
||||
"Connection to socket lost by #{inspect(reason, pretty: true)}; reconnecting..."
|
||||
end)
|
||||
|
||||
:reconnect
|
||||
end
|
||||
|
||||
def handle_disconnect(_code, reason, _state) do
|
||||
Logger.warning(fn ->
|
||||
"Connection to socket lost by #{inspect(reason, pretty: true)}; closing..."
|
||||
end)
|
||||
|
||||
:reconnect
|
||||
end
|
||||
|
||||
def handle_error({error, _reason}, state)
|
||||
when error in [:encoding_failed, :casting_failed],
|
||||
do: {:ignore, state}
|
||||
|
||||
def handle_error(_error, _state), do: :reconnect
|
||||
|
||||
def handle_terminate(reason, _state) do
|
||||
Logger.warning(fn -> "Terminating client process with reason : #{inspect(reason)}" end)
|
||||
end
|
||||
|
||||
defp parse_message(
|
||||
%{
|
||||
"solar_system_id" => solar_system_id,
|
||||
"killmail_time" => killmail_time
|
||||
} = _message
|
||||
) do
|
||||
{:ok, kill_time, _} = DateTime.from_iso8601(killmail_time)
|
||||
|
||||
%{
|
||||
solar_system_id: solar_system_id,
|
||||
kill_time: kill_time
|
||||
}
|
||||
end
|
||||
|
||||
defp parse_message(_message), do: nil
|
||||
def handle_terminate(reason, state),
|
||||
do: Websocket.handle_terminate(reason, state)
|
||||
end
|
||||
|
||||
@@ -8,15 +8,27 @@ defmodule WandererApp.Zkb.Supervisor do
|
||||
end
|
||||
|
||||
def init(_init_args) do
|
||||
children = [
|
||||
{WandererApp.Zkb.KillsProvider,
|
||||
uri: "wss://zkillboard.com/websocket/",
|
||||
state: %WandererApp.Zkb.KillsProvider{},
|
||||
opts: [
|
||||
name: {:local, :zkb_kills_provider},
|
||||
mint_upgrade_opts: [Mint.WebSocket.PerMessageDeflate]
|
||||
]}
|
||||
]
|
||||
preloader_child =
|
||||
unless WandererApp.Env.zkill_preload_disabled?() do
|
||||
{WandererApp.Zkb.KillsPreloader, []}
|
||||
end
|
||||
|
||||
children =
|
||||
[
|
||||
{
|
||||
WandererApp.Zkb.KillsProvider,
|
||||
uri: "wss://zkillboard.com/websocket/",
|
||||
state: %WandererApp.Zkb.KillsProvider{
|
||||
connected: false
|
||||
},
|
||||
opts: [
|
||||
name: {:local, :zkb_kills_provider},
|
||||
mint_upgrade_opts: [Mint.WebSocket.PerMessageDeflate]
|
||||
]
|
||||
},
|
||||
preloader_child
|
||||
]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
165
lib/wanderer_app/zkb/zkills_provider/cache.ex
Normal file
165
lib/wanderer_app/zkb/zkills_provider/cache.ex
Normal file
@@ -0,0 +1,165 @@
|
||||
defmodule WandererApp.Zkb.KillsProvider.KillsCache do
|
||||
@moduledoc """
|
||||
Provides helper functions for putting/fetching kill data
|
||||
"""
|
||||
|
||||
require Logger
|
||||
alias WandererApp.Cache
|
||||
|
||||
@killmail_ttl :timer.hours(24)
|
||||
@system_kills_ttl :timer.hours(1)
|
||||
|
||||
# Base (average) expiry of 15 minutes for "recently fetched" systems
|
||||
@base_full_fetch_expiry_ms 900_000
|
||||
@jitter_percent 0.1
|
||||
|
||||
def killmail_ttl, do: @killmail_ttl
|
||||
def system_kills_ttl, do: @system_kills_ttl
|
||||
|
||||
@doc """
|
||||
Store the killmail data, keyed by killmail_id, with a 24h TTL.
|
||||
"""
|
||||
def put_killmail(killmail_id, kill_data) do
|
||||
Logger.debug("[KillsCache] Storing killmail => killmail_id=#{killmail_id}")
|
||||
Cache.put(killmail_key(killmail_id), kill_data, ttl: @killmail_ttl)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetch kills for `system_id` from the local cache only.
|
||||
Returns a list of killmail maps (could be empty).
|
||||
"""
|
||||
def fetch_cached_kills(system_id) do
|
||||
killmail_ids = get_system_killmail_ids(system_id)
|
||||
# Debug-level log for performance checks
|
||||
Logger.debug("[KillsCache] fetch_cached_kills => system_id=#{system_id}, count=#{length(killmail_ids)}")
|
||||
|
||||
killmail_ids
|
||||
|> Enum.map(&get_killmail/1)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetch cached kills for multiple solar system IDs.
|
||||
Returns a map of `%{ solar_system_id => list_of_kills }`.
|
||||
"""
|
||||
def fetch_cached_kills_for_systems(system_ids) when is_list(system_ids) do
|
||||
Enum.reduce(system_ids, %{}, fn sid, acc ->
|
||||
kills_list = fetch_cached_kills(sid)
|
||||
Map.put(acc, sid, kills_list)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetch the killmail data (if any) from the cache, by killmail_id.
|
||||
"""
|
||||
def get_killmail(killmail_id) do
|
||||
Cache.get(killmail_key(killmail_id))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds `killmail_id` to the list of killmail IDs for the system
|
||||
if it’s not already present. The TTL is 24 hours.
|
||||
"""
|
||||
def add_killmail_id_to_system_list(solar_system_id, killmail_id) do
|
||||
Cache.update(
|
||||
system_kills_list_key(solar_system_id),
|
||||
[],
|
||||
fn existing_list ->
|
||||
existing_list = existing_list || []
|
||||
if killmail_id in existing_list do
|
||||
existing_list
|
||||
else
|
||||
existing_list ++ [killmail_id]
|
||||
end
|
||||
end,
|
||||
ttl: @killmail_ttl
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a list of killmail IDs for the given system, or [] if none.
|
||||
"""
|
||||
def get_system_killmail_ids(solar_system_id) do
|
||||
Cache.get(system_kills_list_key(solar_system_id)) || []
|
||||
end
|
||||
|
||||
@doc """
|
||||
Increments the kill count for a system by `amount`. The TTL is 1 hour.
|
||||
"""
|
||||
def incr_system_kill_count(solar_system_id, amount \\ 1) do
|
||||
Cache.incr(
|
||||
system_kills_key(solar_system_id),
|
||||
amount,
|
||||
default: 0,
|
||||
ttl: @system_kills_ttl
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the integer count of kills for this system in the last hour, or 0.
|
||||
"""
|
||||
def get_system_kill_count(solar_system_id) do
|
||||
Cache.get(system_kills_key(solar_system_id)) || 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Check if the system is still in its "recently fetched" window.
|
||||
We store an `expires_at` timestamp (in ms). If `now < expires_at`,
|
||||
this system is still considered "recently fetched".
|
||||
"""
|
||||
def recently_fetched?(system_id) do
|
||||
case Cache.lookup(fetched_timestamp_key(system_id)) do
|
||||
{:ok, expires_at_ms} when is_integer(expires_at_ms) ->
|
||||
now_ms = current_time_ms()
|
||||
now_ms < expires_at_ms
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Puts a jittered `expires_at` in the cache for `system_id`,
|
||||
marking it as fully fetched for ~15 minutes (+/- 10%).
|
||||
"""
|
||||
def put_full_fetched_timestamp(system_id) do
|
||||
now_ms = current_time_ms()
|
||||
max_jitter = round(@base_full_fetch_expiry_ms * @jitter_percent)
|
||||
# random offset in range [-max_jitter..+max_jitter]
|
||||
offset = :rand.uniform(2 * max_jitter + 1) - (max_jitter + 1)
|
||||
final_expiry_ms = max(@base_full_fetch_expiry_ms + offset, 60_000)
|
||||
expires_at_ms = now_ms + final_expiry_ms
|
||||
|
||||
Logger.debug("[KillsCache] Marking system=#{system_id} recently_fetched? until #{expires_at_ms} (ms)")
|
||||
Cache.put(fetched_timestamp_key(system_id), expires_at_ms)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns how many ms remain until this system's "recently fetched" window ends.
|
||||
If it's already expired (or doesn't exist), returns -1.
|
||||
"""
|
||||
def fetch_age_ms(system_id) do
|
||||
now_ms = current_time_ms()
|
||||
|
||||
case Cache.lookup(fetched_timestamp_key(system_id)) do
|
||||
{:ok, expires_at_ms} when is_integer(expires_at_ms) ->
|
||||
if now_ms < expires_at_ms do
|
||||
expires_at_ms - now_ms
|
||||
else
|
||||
-1
|
||||
end
|
||||
|
||||
_ ->
|
||||
-1
|
||||
end
|
||||
end
|
||||
|
||||
defp killmail_key(killmail_id), do: "zkb_killmail_#{killmail_id}"
|
||||
defp system_kills_key(solar_system_id), do: "zkb_kills_#{solar_system_id}"
|
||||
defp system_kills_list_key(solar_system_id), do: "zkb_kills_list_#{solar_system_id}"
|
||||
defp fetched_timestamp_key(system_id), do: "zkb_system_fetched_at_#{system_id}"
|
||||
|
||||
defp current_time_ms() do
|
||||
DateTime.utc_now() |> DateTime.to_unix(:millisecond)
|
||||
end
|
||||
end
|
||||
211
lib/wanderer_app/zkb/zkills_provider/fetcher.ex
Normal file
211
lib/wanderer_app/zkb/zkills_provider/fetcher.ex
Normal file
@@ -0,0 +1,211 @@
|
||||
defmodule WandererApp.Zkb.KillsProvider.Fetcher do
|
||||
@moduledoc """
|
||||
Low-level API for fetching killmails from zKillboard + ESI.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
use Retry
|
||||
|
||||
alias WandererApp.Zkb.KillsProvider.{Parser, KillsCache, ZkbApi}
|
||||
|
||||
@page_size 200
|
||||
@max_pages 2
|
||||
|
||||
@doc """
|
||||
Fetch killmails for multiple systems, returning a map of system_id => kills.
|
||||
"""
|
||||
def fetch_kills_for_systems(system_ids, since_hours, state, _opts \\ []) when is_list(system_ids) do
|
||||
try do
|
||||
{final_map, final_state} =
|
||||
Enum.reduce(system_ids, {%{}, state}, fn sid, {acc_map, acc_st} ->
|
||||
case fetch_kills_for_system(sid, since_hours, acc_st) do
|
||||
{:ok, kills, new_st} ->
|
||||
{Map.put(acc_map, sid, kills), new_st}
|
||||
|
||||
{:error, reason, new_st} ->
|
||||
Logger.debug("[Fetcher] system=#{sid} => error=#{inspect(reason)}")
|
||||
{Map.put(acc_map, sid, {:error, reason}), new_st}
|
||||
end
|
||||
end)
|
||||
|
||||
Logger.debug("[Fetcher] fetch_kills_for_systems => done, final_map_size=#{map_size(final_map)} calls=#{final_state.calls_count}")
|
||||
{:ok, final_map}
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("[Fetcher] EXCEPTION in fetch_kills_for_systems => #{Exception.message(e)}")
|
||||
{:error, e}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetch killmails for a single system within `since_hours` cutoff.
|
||||
|
||||
Options:
|
||||
- `:limit` => integer limit on how many kills to fetch (optional).
|
||||
If `limit` is nil (or not set), we fetch until we exhaust pages or older kills.
|
||||
- `:force` => if true, ignore the "recently fetched" check and forcibly refetch.
|
||||
|
||||
Returns `{:ok, kills, updated_state}` on success, or `{:error, reason, updated_state}`.
|
||||
"""
|
||||
def fetch_kills_for_system(system_id, since_hours, state, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, nil)
|
||||
force? = Keyword.get(opts, :force, false)
|
||||
|
||||
log_prefix = "[Fetcher] fetch_kills_for_system => system=#{system_id}"
|
||||
|
||||
# Check the "recently fetched" cache if not forced
|
||||
if not force? and KillsCache.recently_fetched?(system_id) do
|
||||
cached_kills = KillsCache.fetch_cached_kills(system_id)
|
||||
final = maybe_take(cached_kills, limit)
|
||||
Logger.debug("#{log_prefix}, recently_fetched?=true => returning #{length(final)} cached kills")
|
||||
{:ok, final, state}
|
||||
else
|
||||
Logger.debug("#{log_prefix}, hours=#{since_hours}, limit=#{inspect(limit)}, force=#{force?}")
|
||||
|
||||
cutoff_dt = hours_ago(since_hours)
|
||||
|
||||
result =
|
||||
retry with: exponential_backoff(300)
|
||||
|> randomize()
|
||||
|> cap(5_000)
|
||||
|> expiry(120_000) do
|
||||
case do_multi_page_fetch(system_id, cutoff_dt, 1, 0, limit, state) do
|
||||
{:ok, new_st, total_fetched} ->
|
||||
# Mark system as fully fetched (to prevent repeated calls).
|
||||
KillsCache.put_full_fetched_timestamp(system_id)
|
||||
final_kills = KillsCache.fetch_cached_kills(system_id) |> maybe_take(limit)
|
||||
|
||||
Logger.debug(
|
||||
"#{log_prefix}, total_fetched=#{total_fetched}, final_cached=#{length(final_kills)}, calls_count=#{new_st.calls_count}"
|
||||
)
|
||||
|
||||
{:ok, final_kills, new_st}
|
||||
|
||||
{:error, :rate_limited, _new_st} ->
|
||||
raise ":rate_limited"
|
||||
|
||||
{:error, reason, _new_st} ->
|
||||
raise "#{log_prefix}, reason=#{inspect(reason)}"
|
||||
end
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, kills, new_st} ->
|
||||
{:ok, kills, new_st}
|
||||
|
||||
error ->
|
||||
Logger.error("#{log_prefix}, EXHAUSTED => error=#{inspect(error)}")
|
||||
{:error, error, state}
|
||||
end
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("[Fetcher] EXCEPTION in fetch_kills_for_system => #{Exception.message(e)}")
|
||||
{:error, e, state}
|
||||
end
|
||||
|
||||
defp do_multi_page_fetch(_system_id, _cutoff_dt, page, total_so_far, _limit, state)
|
||||
when page > @max_pages do
|
||||
# No more pages
|
||||
{:ok, state, total_so_far}
|
||||
end
|
||||
|
||||
defp do_multi_page_fetch(system_id, cutoff_dt, page, total_so_far, limit, state) do
|
||||
Logger.debug(
|
||||
"[Fetcher] do_multi_page_fetch => system=#{system_id}, page=#{page}, total_so_far=#{total_so_far}, limit=#{inspect(limit)}"
|
||||
)
|
||||
|
||||
with {:ok, st1} <- increment_calls_count(state),
|
||||
{:ok, st2, partials} <- ZkbApi.fetch_and_parse_page(system_id, page, st1) do
|
||||
Logger.debug("[Fetcher] system=#{system_id}, page=#{page}, partials_count=#{length(partials)}")
|
||||
|
||||
{_count_stored, older_found?, total_now} =
|
||||
Enum.reduce_while(partials, {0, false, total_so_far}, fn partial, {acc_count, had_older, acc_total} ->
|
||||
# If we have a limit and reached it, stop immediately
|
||||
if reached_limit?(limit, acc_total) do
|
||||
{:halt, {acc_count, had_older, acc_total}}
|
||||
else
|
||||
case parse_partial(partial, cutoff_dt) do
|
||||
:older ->
|
||||
# Found an older kill => we can halt the entire multi-page fetch
|
||||
{:halt, {acc_count, true, acc_total}}
|
||||
|
||||
:ok ->
|
||||
{:cont, {acc_count + 1, false, acc_total + 1}}
|
||||
|
||||
:skip ->
|
||||
{:cont, {acc_count, had_older, acc_total}}
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
cond do
|
||||
# If we found older kills, stop now
|
||||
older_found? ->
|
||||
{:ok, st2, total_now}
|
||||
|
||||
# If we have a limit and just reached or exceeded it
|
||||
reached_limit?(limit, total_now) ->
|
||||
{:ok, st2, total_now}
|
||||
|
||||
# If partials < @page_size, no more kills are left
|
||||
length(partials) < @page_size ->
|
||||
{:ok, st2, total_now}
|
||||
|
||||
# Otherwise, keep going to next page
|
||||
true ->
|
||||
do_multi_page_fetch(system_id, cutoff_dt, page + 1, total_now, limit, st2)
|
||||
end
|
||||
else
|
||||
{:error, :rate_limited, stx} ->
|
||||
{:error, :rate_limited, stx}
|
||||
|
||||
{:error, reason, stx} ->
|
||||
{:error, reason, stx}
|
||||
|
||||
other ->
|
||||
Logger.warning("[Fetcher] Unexpected result => #{inspect(other)}")
|
||||
{:error, :unexpected, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_partial(%{"killmail_id" => kill_id, "zkb" => %{"hash" => kill_hash}} = partial, cutoff_dt) do
|
||||
# If we've already cached this kill, skip
|
||||
if KillsCache.get_killmail(kill_id) do
|
||||
:skip
|
||||
else
|
||||
# Actually fetch the full kill from ESI
|
||||
case fetch_full_killmail(kill_id, kill_hash) do
|
||||
{:ok, full_km} ->
|
||||
# Delegate the time check & storing to Parser
|
||||
Parser.parse_full_and_store(full_km, partial, cutoff_dt)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("[Fetcher] ESI fail => kill_id=#{kill_id}, reason=#{inspect(reason)}")
|
||||
:skip
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_partial(_other, _cutoff_dt), do: :skip
|
||||
|
||||
defp fetch_full_killmail(k_id, k_hash) do
|
||||
case WandererApp.Esi.get_killmail(k_id, k_hash) do
|
||||
{:ok, full_km} -> {:ok, full_km}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp hours_ago(h),
|
||||
do: DateTime.utc_now() |> DateTime.add(-h * 3600, :second)
|
||||
|
||||
defp increment_calls_count(%{calls_count: c} = st),
|
||||
do: {:ok, %{st | calls_count: c + 1}}
|
||||
|
||||
defp reached_limit?(nil, _count_so_far), do: false
|
||||
defp reached_limit?(limit, count_so_far) when is_integer(limit),
|
||||
do: count_so_far >= limit
|
||||
|
||||
defp maybe_take(kills, nil), do: kills
|
||||
defp maybe_take(kills, limit), do: Enum.take(kills, limit)
|
||||
end
|
||||
327
lib/wanderer_app/zkb/zkills_provider/parser.ex
Normal file
327
lib/wanderer_app/zkb/zkills_provider/parser.ex
Normal file
@@ -0,0 +1,327 @@
|
||||
defmodule WandererApp.Zkb.KillsProvider.Parser do
|
||||
@moduledoc """
|
||||
Helper for parsing & storing a killmail from the ESI data (plus zKB partial).
|
||||
Responsible for:
|
||||
- Parsing the raw JSON structures,
|
||||
- Combining partial & full kill data,
|
||||
- Checking whether kills are 'too old',
|
||||
- Storing in KillsCache, etc.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
alias WandererApp.Zkb.KillsProvider.KillsCache
|
||||
|
||||
@doc """
|
||||
Merges the 'partial' from zKB and the 'full' killmail from ESI, checks its time
|
||||
vs. `cutoff_dt`.
|
||||
|
||||
Returns:
|
||||
- `:ok` if we parsed & stored successfully,
|
||||
- `:older` if killmail time is older than `cutoff_dt`,
|
||||
- `:skip` if we cannot parse or store for some reason.
|
||||
"""
|
||||
def parse_full_and_store(full_km, partial_zkb, cutoff_dt) when is_map(full_km) do
|
||||
# Attempt to parse the killmail_time
|
||||
case parse_killmail_time(full_km) do
|
||||
{:ok, km_dt} ->
|
||||
if older_than_cutoff?(km_dt, cutoff_dt) do
|
||||
:older
|
||||
else
|
||||
# Merge the "zkb" portion from the partial into the full killmail
|
||||
enriched = Map.merge(full_km, %{"zkb" => partial_zkb["zkb"]})
|
||||
parse_and_store_killmail(enriched)
|
||||
end
|
||||
|
||||
_ ->
|
||||
:skip
|
||||
end
|
||||
end
|
||||
|
||||
def parse_full_and_store(_full_km, _partial_zkb, _cutoff_dt),
|
||||
do: :skip
|
||||
|
||||
@doc """
|
||||
Parse a raw killmail (`full_km`) and store it if valid.
|
||||
Returns:
|
||||
- `:ok` if successfully parsed & stored,
|
||||
- `:skip` otherwise
|
||||
"""
|
||||
def parse_and_store_killmail(%{"killmail_id" => _kill_id} = full_km) do
|
||||
parsed_map = do_parse(full_km)
|
||||
|
||||
if is_nil(parsed_map) or is_nil(parsed_map["kill_time"]) do
|
||||
:skip
|
||||
else
|
||||
store_killmail(parsed_map)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
def parse_and_store_killmail(_),
|
||||
do: :skip
|
||||
|
||||
defp do_parse(%{"killmail_id" => kill_id} = km) do
|
||||
victim = Map.get(km, "victim", %{})
|
||||
attackers = Map.get(km, "attackers", [])
|
||||
|
||||
kill_time_dt =
|
||||
case DateTime.from_iso8601("#{Map.get(km, "killmail_time", "")}") do
|
||||
{:ok, dt, _off} -> dt
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
npc_flag = get_in(km, ["zkb", "npc"]) || false
|
||||
|
||||
%{
|
||||
"killmail_id" => kill_id,
|
||||
"kill_time" => kill_time_dt,
|
||||
"solar_system_id" => km["solar_system_id"],
|
||||
"zkb" => Map.get(km, "zkb", %{}),
|
||||
"attacker_count" => length(attackers),
|
||||
"total_value" => get_in(km, ["zkb", "totalValue"]) || 0,
|
||||
"victim" => victim,
|
||||
"attackers" => attackers,
|
||||
"npc" => npc_flag
|
||||
}
|
||||
end
|
||||
|
||||
defp do_parse(_),
|
||||
do: nil
|
||||
|
||||
@doc """
|
||||
Extracts & returns {:ok, DateTime} from the "killmail_time" field, or :skip on failure.
|
||||
"""
|
||||
def parse_killmail_time(full_km) do
|
||||
killmail_time_str = Map.get(full_km, "killmail_time", "")
|
||||
|
||||
case DateTime.from_iso8601(killmail_time_str) do
|
||||
{:ok, dt, _offset} ->
|
||||
{:ok, dt}
|
||||
|
||||
_ ->
|
||||
:skip
|
||||
end
|
||||
end
|
||||
|
||||
defp older_than_cutoff?(%DateTime{} = dt, %DateTime{} = cutoff_dt),
|
||||
do: DateTime.compare(dt, cutoff_dt) == :lt
|
||||
|
||||
defp store_killmail(%{"killmail_id" => nil}), do: :ok
|
||||
|
||||
defp store_killmail(%{"killmail_id" => kill_id} = parsed) do
|
||||
final = build_kill_data(parsed)
|
||||
|
||||
if final do
|
||||
enriched = maybe_enrich_killmail(final)
|
||||
KillsCache.put_killmail(kill_id, enriched)
|
||||
|
||||
system_id = enriched["solar_system_id"]
|
||||
KillsCache.add_killmail_id_to_system_list(system_id, kill_id)
|
||||
|
||||
if within_last_hour?(enriched["kill_time"]) do
|
||||
KillsCache.incr_system_kill_count(system_id)
|
||||
end
|
||||
else
|
||||
Logger.warning("[Parser] store_killmail => build_kill_data returned nil for kill_id=#{kill_id}")
|
||||
end
|
||||
end
|
||||
|
||||
defp store_killmail(_),
|
||||
do: :ok
|
||||
|
||||
defp build_kill_data(%{
|
||||
"killmail_id" => kill_id,
|
||||
"kill_time" => kill_time_dt,
|
||||
"solar_system_id" => sys_id,
|
||||
"zkb" => zkb,
|
||||
"victim" => victim,
|
||||
"attackers" => attackers,
|
||||
"attacker_count" => attacker_count,
|
||||
"total_value" => total_value,
|
||||
"npc" => npc
|
||||
}) do
|
||||
|
||||
victim_map = extract_victim_fields(victim)
|
||||
final_blow_map = extract_final_blow_fields(attackers)
|
||||
|
||||
%{
|
||||
"killmail_id" => kill_id,
|
||||
"kill_time" => kill_time_dt,
|
||||
"solar_system_id" => sys_id,
|
||||
"zkb" => zkb,
|
||||
|
||||
"victim_char_id" => victim_map.char_id,
|
||||
"victim_corp_id" => victim_map.corp_id,
|
||||
"victim_alliance_id" => victim_map.alliance_id,
|
||||
"victim_ship_type_id" => victim_map.ship_type_id,
|
||||
|
||||
"final_blow_char_id" => final_blow_map.char_id,
|
||||
"final_blow_corp_id" => final_blow_map.corp_id,
|
||||
"final_blow_alliance_id" => final_blow_map.alliance_id,
|
||||
"final_blow_ship_type_id" => final_blow_map.ship_type_id,
|
||||
|
||||
"attacker_count" => attacker_count,
|
||||
"total_value" => total_value,
|
||||
"npc" => npc
|
||||
}
|
||||
end
|
||||
|
||||
defp build_kill_data(_),
|
||||
do: nil
|
||||
|
||||
defp extract_victim_fields(%{
|
||||
"character_id" => cid,
|
||||
"corporation_id" => corp,
|
||||
"alliance_id" => alli,
|
||||
"ship_type_id" => st_id
|
||||
}),
|
||||
do: %{char_id: cid, corp_id: corp, alliance_id: alli, ship_type_id: st_id}
|
||||
|
||||
defp extract_victim_fields(%{
|
||||
"character_id" => cid,
|
||||
"corporation_id" => corp,
|
||||
"ship_type_id" => st_id
|
||||
}),
|
||||
do: %{char_id: cid, corp_id: corp, alliance_id: nil, ship_type_id: st_id}
|
||||
|
||||
defp extract_victim_fields(_),
|
||||
do: %{char_id: nil, corp_id: nil, alliance_id: nil, ship_type_id: nil}
|
||||
|
||||
defp extract_final_blow_fields(attackers) when is_list(attackers) do
|
||||
final = Enum.find(attackers, fn a -> a["final_blow"] == true end)
|
||||
extract_attacker_fields(final)
|
||||
end
|
||||
|
||||
defp extract_final_blow_fields(_),
|
||||
do: %{char_id: nil, corp_id: nil, alliance_id: nil, ship_type_id: nil}
|
||||
|
||||
defp extract_attacker_fields(nil),
|
||||
do: %{char_id: nil, corp_id: nil, alliance_id: nil, ship_type_id: nil}
|
||||
|
||||
defp extract_attacker_fields(%{
|
||||
"character_id" => cid,
|
||||
"corporation_id" => corp,
|
||||
"alliance_id" => alli,
|
||||
"ship_type_id" => st_id
|
||||
}),
|
||||
do: %{char_id: cid, corp_id: corp, alliance_id: alli, ship_type_id: st_id}
|
||||
|
||||
defp extract_attacker_fields(%{
|
||||
"character_id" => cid,
|
||||
"corporation_id" => corp,
|
||||
"ship_type_id" => st_id
|
||||
}),
|
||||
do: %{char_id: cid, corp_id: corp, alliance_id: nil, ship_type_id: st_id}
|
||||
|
||||
defp extract_attacker_fields(%{"ship_type_id" => st_id} = attacker) do
|
||||
%{
|
||||
char_id: Map.get(attacker, "character_id"),
|
||||
corp_id: Map.get(attacker, "corporation_id"),
|
||||
alliance_id: Map.get(attacker, "alliance_id"),
|
||||
ship_type_id: st_id
|
||||
}
|
||||
end
|
||||
|
||||
defp extract_attacker_fields(_),
|
||||
do: %{char_id: nil, corp_id: nil, alliance_id: nil, ship_type_id: nil}
|
||||
|
||||
defp maybe_enrich_killmail(km) do
|
||||
km
|
||||
|> enrich_victim()
|
||||
|> enrich_final_blow()
|
||||
end
|
||||
|
||||
|
||||
defp enrich_victim(km) do
|
||||
km
|
||||
|> maybe_put_character_name("victim_char_id", "victim_char_name")
|
||||
|> maybe_put_corp_info("victim_corp_id", "victim_corp_ticker", "victim_corp_name")
|
||||
|> maybe_put_alliance_info("victim_alliance_id", "victim_alliance_ticker", "victim_alliance_name")
|
||||
|> maybe_put_ship_name("victim_ship_type_id", "victim_ship_name")
|
||||
end
|
||||
|
||||
|
||||
defp enrich_final_blow(km) do
|
||||
km
|
||||
|> maybe_put_character_name("final_blow_char_id", "final_blow_char_name")
|
||||
|> maybe_put_corp_info("final_blow_corp_id", "final_blow_corp_ticker", "final_blow_corp_name")
|
||||
|> maybe_put_alliance_info("final_blow_alliance_id", "final_blow_alliance_ticker", "final_blow_alliance_name")
|
||||
|> maybe_put_ship_name("final_blow_ship_type_id", "final_blow_ship_name")
|
||||
end
|
||||
|
||||
defp maybe_put_character_name(km, id_key, name_key) do
|
||||
case Map.get(km, id_key) do
|
||||
nil -> km
|
||||
0 -> km
|
||||
eve_id ->
|
||||
case WandererApp.Esi.get_character_info(eve_id) do
|
||||
{:ok, %{"name" => char_name}} ->
|
||||
Map.put(km, name_key, char_name)
|
||||
|
||||
_ ->
|
||||
km
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_corp_info(km, id_key, ticker_key, name_key) do
|
||||
case Map.get(km, id_key) do
|
||||
nil -> km
|
||||
0 -> km
|
||||
corp_id ->
|
||||
case WandererApp.Esi.get_corporation_info(corp_id) do
|
||||
{:ok, %{"ticker" => ticker, "name" => corp_name}} ->
|
||||
km
|
||||
|> Map.put(ticker_key, ticker)
|
||||
|> Map.put(name_key, corp_name)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("[Parser] Failed to fetch corp info: ID=#{corp_id}, reason=#{inspect(reason)}")
|
||||
km
|
||||
|
||||
_ ->
|
||||
km
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_alliance_info(km, id_key, ticker_key, name_key) do
|
||||
case Map.get(km, id_key) do
|
||||
nil -> km
|
||||
0 -> km
|
||||
alliance_id ->
|
||||
case WandererApp.Esi.get_alliance_info(alliance_id) do
|
||||
{:ok, %{"ticker" => alliance_ticker, "name" => alliance_name}} ->
|
||||
km
|
||||
|> Map.put(ticker_key, alliance_ticker)
|
||||
|> Map.put(name_key, alliance_name)
|
||||
|
||||
_ ->
|
||||
km
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_ship_name(km, id_key, name_key) do
|
||||
case Map.get(km, id_key) do
|
||||
nil -> km
|
||||
0 -> km
|
||||
type_id ->
|
||||
case WandererApp.CachedInfo.get_ship_type(type_id) do
|
||||
{:ok, nil} -> km
|
||||
{:ok, %{name: ship_name}} -> Map.put(km, name_key, ship_name)
|
||||
{:error, reason} ->
|
||||
Logger.warning("[Parser] Failed to fetch ship type: ID=#{type_id}, reason=#{inspect(reason)}")
|
||||
km
|
||||
|
||||
_ -> km
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Utility
|
||||
defp within_last_hour?(nil), do: false
|
||||
|
||||
defp within_last_hour?(%DateTime{} = dt),
|
||||
do: DateTime.diff(DateTime.utc_now(), dt, :minute) < 60
|
||||
end
|
||||
86
lib/wanderer_app/zkb/zkills_provider/websocket.ex
Normal file
86
lib/wanderer_app/zkb/zkills_provider/websocket.ex
Normal file
@@ -0,0 +1,86 @@
|
||||
defmodule WandererApp.Zkb.KillsProvider.Websocket do
|
||||
@moduledoc """
|
||||
Handles real-time kills from zKillboard WebSocket.
|
||||
Always fetches from ESI to get killmail_time, victim, attackers, etc.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
alias WandererApp.Zkb.KillsProvider.Parser
|
||||
alias WandererApp.Esi
|
||||
|
||||
@heartbeat_interval 1_000
|
||||
|
||||
# Called by `KillsProvider.handle_connect`
|
||||
def handle_connect(_status, _headers, %{connected: _} = state) do
|
||||
Logger.info("[KillsProvider.Websocket] Connected => killstream")
|
||||
new_state = Map.put(state, :connected, true)
|
||||
handle_subscribe("killstream", new_state)
|
||||
end
|
||||
|
||||
# Called by `KillsProvider.handle_in`
|
||||
def handle_in({:text, frame}, state) do
|
||||
Logger.debug("[KillsProvider.Websocket] Received frame => #{frame}")
|
||||
partial = Jason.decode!(frame)
|
||||
parse_and_store_zkb_partial(partial)
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
# Called for control frames
|
||||
def handle_control({:pong, _msg}, state),
|
||||
do: {:ok, state}
|
||||
|
||||
def handle_control({:ping, _}, state) do
|
||||
Process.send_after(self(), :heartbeat, @heartbeat_interval)
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
# Called by the process mailbox
|
||||
def handle_info(:heartbeat, state) do
|
||||
payload = Jason.encode!(%{"action" => "pong"})
|
||||
{:reply, {:text, payload}, state}
|
||||
end
|
||||
|
||||
def handle_info(_other, state), do: {:ok, state}
|
||||
|
||||
# Called on disconnect
|
||||
def handle_disconnect(code, reason, _old_state) do
|
||||
Logger.warning("[KillsProvider.Websocket] Disconnected => code=#{code}, reason=#{inspect(reason)} => reconnecting")
|
||||
:reconnect
|
||||
end
|
||||
|
||||
# Called on errors
|
||||
def handle_error({err, _reason}, state) when err in [:encoding_failed, :casting_failed],
|
||||
do: {:ignore, state}
|
||||
|
||||
def handle_error(_error, _state),
|
||||
do: :reconnect
|
||||
|
||||
# Called on terminate
|
||||
def handle_terminate(reason, _state) do
|
||||
Logger.warning("[KillsProvider.Websocket] Terminating => #{inspect(reason)}")
|
||||
end
|
||||
|
||||
defp handle_subscribe(channel, state) do
|
||||
Logger.debug("[KillsProvider.Websocket] Subscribing to #{channel}")
|
||||
payload = Jason.encode!(%{"action" => "sub", "channel" => channel})
|
||||
{:reply, {:text, payload}, state}
|
||||
end
|
||||
|
||||
# The partial from zKillboard has killmail_id + zkb.hash, but no time/victim/attackers
|
||||
defp parse_and_store_zkb_partial(%{"killmail_id" => kill_id, "zkb" => %{"hash" => kill_hash}} = partial) do
|
||||
Logger.debug("[KillsProvider.Websocket] parse_and_store_zkb_partial => kill_id=#{kill_id}")
|
||||
case Esi.get_killmail(kill_id, kill_hash) do
|
||||
{:ok, full_esi_data} ->
|
||||
# Merge partial zKB fields (like totalValue) onto ESI data
|
||||
enriched = Map.merge(full_esi_data, %{"zkb" => partial["zkb"]})
|
||||
Parser.parse_and_store_killmail(enriched)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("[KillsProvider.Websocket] ESI get_killmail failed => kill_id=#{kill_id}, reason=#{inspect(reason)}")
|
||||
:skip
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_and_store_zkb_partial(_),
|
||||
do: :skip
|
||||
end
|
||||
79
lib/wanderer_app/zkb/zkills_provider/zkb_api.ex
Normal file
79
lib/wanderer_app/zkb/zkills_provider/zkb_api.ex
Normal file
@@ -0,0 +1,79 @@
|
||||
defmodule WandererApp.Zkb.KillsProvider.ZkbApi do
|
||||
@moduledoc """
|
||||
A small module for making HTTP requests to zKillboard and
|
||||
parsing JSON responses, separate from the multi-page logic.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
alias ExRated
|
||||
|
||||
# 5 calls per second allowed
|
||||
@exrated_bucket :zkb_preloader_provider
|
||||
@exrated_interval_ms 1_000
|
||||
@exrated_max_requests 5
|
||||
|
||||
@zkillboard_api "https://zkillboard.com/api"
|
||||
|
||||
@doc """
|
||||
Perform rate-limit check before fetching a single page from zKillboard and parse the response.
|
||||
|
||||
Returns:
|
||||
- `{:ok, updated_state, partials_list}` on success
|
||||
- `{:error, reason, updated_state}` if error
|
||||
"""
|
||||
def fetch_and_parse_page(system_id, page, %{calls_count: _} = state) do
|
||||
with :ok <- check_rate(),
|
||||
{:ok, resp} <- do_req_get(system_id, page),
|
||||
partials when is_list(partials) <- parse_response_body(resp) do
|
||||
{:ok, state, partials}
|
||||
else
|
||||
{:error, :rate_limited} ->
|
||||
{:error, :rate_limited, state}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason, state}
|
||||
|
||||
_other ->
|
||||
{:error, :unexpected, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_req_get(system_id, page) do
|
||||
url = "#{@zkillboard_api}/kills/systemID/#{system_id}/page/#{page}/"
|
||||
Logger.debug("[ZkbApi] GET => system=#{system_id}, page=#{page}, url=#{url}")
|
||||
|
||||
try do
|
||||
resp = Req.get!(url, decode_body: :json)
|
||||
|
||||
if resp.status == 200 do
|
||||
{:ok, resp}
|
||||
else
|
||||
{:error, {:http_status, resp.status}}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("""
|
||||
[ZkbApi] do_req_get => exception: #{Exception.message(e)}
|
||||
#{Exception.format_stacktrace(__STACKTRACE__)}
|
||||
""")
|
||||
{:error, :exception}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_response_body(%{status: 200, body: body}) when is_list(body),
|
||||
do: body
|
||||
|
||||
defp parse_response_body(_),
|
||||
do: :not_list
|
||||
|
||||
defp check_rate do
|
||||
case ExRated.check_rate(@exrated_bucket, @exrated_interval_ms, @exrated_max_requests) do
|
||||
{:ok, _count} ->
|
||||
:ok
|
||||
|
||||
{:error, limit} ->
|
||||
Logger.debug("[ZkbApi] RATE_LIMIT => limit=#{inspect(limit)}")
|
||||
{:error, :rate_limited}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -66,16 +66,12 @@ defmodule WandererAppWeb.Layouts do
|
||||
|
||||
def feedback_container(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id="feeback-container"
|
||||
data-az-l="6e9c41f4-8f3f-4e3b-bbc6-e808f9e46808"
|
||||
class={[
|
||||
"flex flex-col p-4 items-center absolute bottom-40 left-1 gap-2 tooltip tooltip-right text-gray-400 hover:text-white"
|
||||
]}
|
||||
data-tip="Leave Feedback"
|
||||
<.link
|
||||
href="https://discord.gg/cafERvDD2k"
|
||||
class="flex flex-col p-4 items-center absolute bottom-40 left-1 gap-2 tooltip tooltip-right text-gray-400 hover:text-white"
|
||||
>
|
||||
<.icon name="hero-hand-thumb-up-solid" class="h-4 w-4" />
|
||||
</div>
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ defmodule WandererAppWeb.UserActivity do
|
||||
</p>
|
||||
|
||||
<p class="text-sm text-[var(--color-gray-4)] w-[15%]">
|
||||
<%= _get_event_name(@activity.event_type) %>
|
||||
<%= get_event_name(@activity.event_type) %>
|
||||
</p>
|
||||
<.activity_event event_type={@activity.event_type} event_data={@activity.event_data} />
|
||||
|
||||
@@ -115,7 +115,7 @@ defmodule WandererAppWeb.UserActivity do
|
||||
<div class="w-[40%]">
|
||||
<div class="flex items-center gap-1">
|
||||
<h6 class="text-base leading-[150%] font-semibold dark:text-white">
|
||||
<%= _get_event_data(@event_type, Jason.decode!(@event_data) |> Map.drop(["character_id"])) %>
|
||||
<%= get_event_data(@event_type, Jason.decode!(@event_data) |> Map.drop(["character_id"])) %>
|
||||
</h6>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,107 +129,128 @@ defmodule WandererAppWeb.UserActivity do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp _get_event_name(:hub_added), do: "Hub Added"
|
||||
defp _get_event_name(:hub_removed), do: "Hub Removed"
|
||||
defp _get_event_name(:map_connection_added), do: "Connection Added"
|
||||
defp _get_event_name(:map_connection_updated), do: "Connection Updated"
|
||||
defp _get_event_name(:map_connection_removed), do: "Connection Removed"
|
||||
defp _get_event_name(:map_acl_added), do: "Acl Added"
|
||||
defp _get_event_name(:map_acl_removed), do: "Acl Removed"
|
||||
defp _get_event_name(:system_added), do: "System Added"
|
||||
defp _get_event_name(:system_updated), do: "System Updated"
|
||||
defp _get_event_name(:systems_removed), do: "System(s) Removed"
|
||||
defp _get_event_name(name), do: name
|
||||
defp get_event_name(:hub_added), do: "Hub Added"
|
||||
defp get_event_name(:hub_removed), do: "Hub Removed"
|
||||
defp get_event_name(:map_connection_added), do: "Connection Added"
|
||||
defp get_event_name(:map_connection_updated), do: "Connection Updated"
|
||||
defp get_event_name(:map_connection_removed), do: "Connection Removed"
|
||||
defp get_event_name(:map_acl_added), do: "Acl Added"
|
||||
defp get_event_name(:map_acl_removed), do: "Acl Removed"
|
||||
defp get_event_name(:system_added), do: "System Added"
|
||||
defp get_event_name(:system_updated), do: "System Updated"
|
||||
defp get_event_name(:systems_removed), do: "System(s) Removed"
|
||||
defp get_event_name(:signatures_added), do: "Signatures Added"
|
||||
defp get_event_name(:signatures_removed), do: "Signatures Removed"
|
||||
defp get_event_name(name), do: name
|
||||
|
||||
# defp _get_event_data(:hub_added, data), do: Jason.encode!(data)
|
||||
# defp _get_event_data(:hub_removed, data), do: data
|
||||
defp get_event_data(:map_acl_added, %{"acl_id" => acl_id}) do
|
||||
{:ok, acl} = WandererApp.AccessListRepo.get(acl_id)
|
||||
"#{acl.name}"
|
||||
end
|
||||
|
||||
# defp _get_event_data(:map_acl_added, data), do: data
|
||||
# defp _get_event_data(:map_acl_removed, data), do: data
|
||||
# defp _get_event_data(:system_added, data), do: data
|
||||
defp get_event_data(:map_acl_removed, %{"acl_id" => acl_id}) do
|
||||
{:ok, acl} = WandererApp.AccessListRepo.get(acl_id)
|
||||
"#{acl.name}"
|
||||
end
|
||||
|
||||
# defp get_event_data(:map_acl_removed, data), do: data
|
||||
# defp get_event_data(:system_added, data), do: data
|
||||
#
|
||||
|
||||
defp _get_event_data(:system_updated, %{
|
||||
defp get_event_data(:system_updated, %{
|
||||
"key" => "labels",
|
||||
"solar_system_id" => solar_system_id,
|
||||
"value" => value
|
||||
}) do
|
||||
system_name = _get_system_name(solar_system_id)
|
||||
system_name = get_system_name(solar_system_id)
|
||||
|
||||
try do
|
||||
%{"customLabel" => customLabel, "labels" => labels} = Jason.decode!(value)
|
||||
|
||||
"#{system_name} labels - #{inspect(labels)}, customLabel - #{customLabel}"
|
||||
"#{system_name}: labels - #{inspect(labels)}, customLabel - #{customLabel}"
|
||||
rescue
|
||||
_ ->
|
||||
"#{system_name} labels - #{inspect(value)}"
|
||||
"#{system_name}: labels - #{inspect(value)}"
|
||||
end
|
||||
end
|
||||
|
||||
defp _get_event_data(:system_added, %{
|
||||
defp get_event_data(:system_added, %{
|
||||
"solar_system_id" => solar_system_id
|
||||
}),
|
||||
do: _get_system_name(solar_system_id)
|
||||
do: get_system_name(solar_system_id)
|
||||
|
||||
defp _get_event_data(:hub_added, %{
|
||||
defp get_event_data(:hub_added, %{
|
||||
"solar_system_id" => solar_system_id
|
||||
}),
|
||||
do: _get_system_name(solar_system_id)
|
||||
do: get_system_name(solar_system_id)
|
||||
|
||||
defp _get_event_data(:hub_removed, %{
|
||||
defp get_event_data(:hub_removed, %{
|
||||
"solar_system_id" => solar_system_id
|
||||
}),
|
||||
do: _get_system_name(solar_system_id)
|
||||
do: get_system_name(solar_system_id)
|
||||
|
||||
defp _get_event_data(:system_updated, %{
|
||||
defp get_event_data(:system_updated, %{
|
||||
"key" => key,
|
||||
"solar_system_id" => solar_system_id,
|
||||
"value" => value
|
||||
}) do
|
||||
system_name = _get_system_name(solar_system_id)
|
||||
"#{system_name} #{key} - #{inspect(value)}"
|
||||
system_name = get_system_name(solar_system_id)
|
||||
"#{system_name}: #{key} - #{inspect(value)}"
|
||||
end
|
||||
|
||||
defp _get_event_data(:systems_removed, %{
|
||||
defp get_event_data(:systems_removed, %{
|
||||
"solar_system_ids" => solar_system_ids
|
||||
}),
|
||||
do:
|
||||
solar_system_ids
|
||||
|> Enum.map(&_get_system_name/1)
|
||||
|> Enum.map(&get_system_name/1)
|
||||
|> Enum.join(", ")
|
||||
|
||||
defp _get_event_data(:map_connection_added, %{
|
||||
defp get_event_data(signatures_event, %{
|
||||
"solar_system_id" => solar_system_id,
|
||||
"signatures" => signatures
|
||||
})
|
||||
when signatures_event in [:signatures_added, :signatures_removed],
|
||||
do: "#{get_system_name(solar_system_id)}: #{signatures |> Enum.join(", ")}"
|
||||
|
||||
defp get_event_data(signatures_event, %{
|
||||
"signatures" => signatures
|
||||
})
|
||||
when signatures_event in [:signatures_added, :signatures_removed],
|
||||
do: signatures |> Enum.join(", ")
|
||||
|
||||
defp get_event_data(:map_connection_added, %{
|
||||
"solar_system_source_id" => solar_system_source_id,
|
||||
"solar_system_target_id" => solar_system_target_id
|
||||
}) do
|
||||
source_system_name = _get_system_name(solar_system_source_id)
|
||||
target_system_name = _get_system_name(solar_system_target_id)
|
||||
source_system_name = get_system_name(solar_system_source_id)
|
||||
target_system_name = get_system_name(solar_system_target_id)
|
||||
"[#{source_system_name}:#{target_system_name}]"
|
||||
end
|
||||
|
||||
defp _get_event_data(:map_connection_removed, %{
|
||||
defp get_event_data(:map_connection_removed, %{
|
||||
"solar_system_source_id" => solar_system_source_id,
|
||||
"solar_system_target_id" => solar_system_target_id
|
||||
}) do
|
||||
source_system_name = _get_system_name(solar_system_source_id)
|
||||
target_system_name = _get_system_name(solar_system_target_id)
|
||||
source_system_name = get_system_name(solar_system_source_id)
|
||||
target_system_name = get_system_name(solar_system_target_id)
|
||||
"[#{source_system_name}:#{target_system_name}]"
|
||||
end
|
||||
|
||||
defp _get_event_data(:map_connection_updated, %{
|
||||
defp get_event_data(:map_connection_updated, %{
|
||||
"key" => key,
|
||||
"solar_system_source_id" => solar_system_source_id,
|
||||
"solar_system_target_id" => solar_system_target_id,
|
||||
"value" => value
|
||||
}) do
|
||||
source_system_name = _get_system_name(solar_system_source_id)
|
||||
target_system_name = _get_system_name(solar_system_target_id)
|
||||
source_system_name = get_system_name(solar_system_source_id)
|
||||
target_system_name = get_system_name(solar_system_target_id)
|
||||
"[#{source_system_name}:#{target_system_name}] #{key} - #{inspect(value)}"
|
||||
end
|
||||
|
||||
defp _get_event_data(_name, data), do: Jason.encode!(data)
|
||||
defp get_event_data(_name, data), do: Jason.encode!(data)
|
||||
|
||||
defp _get_system_name(solar_system_id) do
|
||||
defp get_system_name(solar_system_id) do
|
||||
case WandererApp.CachedInfo.get_system_static_info(solar_system_id) do
|
||||
{:ok, nil} ->
|
||||
solar_system_id
|
||||
|
||||
@@ -156,7 +156,7 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
|
||||
|
||||
%{result: characters} = socket.assigns.characters
|
||||
|
||||
{:ok, map_characters} = get_tracked_map_characters(map_id, current_user)
|
||||
{:ok, map_characters} = WandererApp.Maps.get_tracked_map_characters(map_id, current_user)
|
||||
|
||||
user_character_eve_ids = map_characters |> Enum.map(& &1.eve_id)
|
||||
|
||||
@@ -204,7 +204,7 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
|
||||
{:ok, all_settings} = WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id)
|
||||
|
||||
# Find and filter user's characters
|
||||
{:ok, user_characters} = get_tracked_map_characters(map_id, current_user)
|
||||
{:ok, user_characters} = WandererApp.Maps.get_tracked_map_characters(map_id, current_user)
|
||||
user_char_ids = Enum.map(user_characters, & &1.id)
|
||||
|
||||
my_settings =
|
||||
@@ -213,7 +213,7 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
|
||||
s.character_id in user_char_ids
|
||||
end)
|
||||
|
||||
existing = Enum.find(my_settings, &(&1.character_id == clicked_char_id))
|
||||
existing = Enum.find(all_settings, &(&1.character_id == clicked_char_id))
|
||||
|
||||
{:ok, target_setting} =
|
||||
if not is_nil(existing) do
|
||||
@@ -251,7 +251,7 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
|
||||
# re-fetch or re-map to confirm final results in UI
|
||||
%{result: characters} = socket.assigns.characters
|
||||
|
||||
{:ok, tracked_characters} = get_tracked_map_characters(map_id, current_user)
|
||||
{:ok, tracked_characters} = WandererApp.Maps.get_tracked_map_characters(map_id, current_user)
|
||||
user_eve_ids = Enum.map(tracked_characters, & &1.eve_id)
|
||||
|
||||
{:ok, final_settings} = WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id)
|
||||
@@ -316,21 +316,6 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
|
||||
def has_tracked_characters?([]), do: false
|
||||
def has_tracked_characters?(_user_characters), do: true
|
||||
|
||||
def get_tracked_map_characters(map_id, current_user) do
|
||||
case WandererApp.MapCharacterSettingsRepo.get_tracked_by_map_filtered(
|
||||
map_id,
|
||||
current_user.characters |> Enum.map(& &1.id)
|
||||
) do
|
||||
{:ok, settings} ->
|
||||
{:ok,
|
||||
settings
|
||||
|> Enum.map(fn s -> s |> Ash.load!(:character) |> Map.get(:character) end)}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end
|
||||
|
||||
def map_ui_character(character),
|
||||
do:
|
||||
character
|
||||
|
||||
@@ -119,10 +119,9 @@ defmodule WandererAppWeb.MapCoreEventHandler do
|
||||
),
|
||||
do: socket
|
||||
|
||||
def handle_server_event(%{event: :structures_updated, payload: _solar_system_id}, socket) do
|
||||
socket
|
||||
end
|
||||
|
||||
def handle_server_event(%{event: :structures_updated, payload: _solar_system_id}, socket) do
|
||||
socket
|
||||
end
|
||||
|
||||
def handle_server_event(event, socket) do
|
||||
Logger.warning(fn -> "unhandled map core event: #{inspect(event)} #{inspect(socket)} " end)
|
||||
@@ -271,6 +270,8 @@ defmodule WandererAppWeb.MapCoreEventHandler do
|
||||
current_user.characters |> Enum.map(& &1.id)
|
||||
)
|
||||
|
||||
{:ok, map_user_settings} = WandererApp.MapUserSettingsRepo.get(map_id, current_user.id)
|
||||
|
||||
{:ok, character_settings} =
|
||||
case WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id) do
|
||||
{:ok, settings} -> {:ok, settings}
|
||||
@@ -303,6 +304,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do
|
||||
socket
|
||||
|> assign(
|
||||
map_id: map_id,
|
||||
map_user_settings: map_user_settings,
|
||||
page_title: map_name,
|
||||
user_permissions: user_permissions,
|
||||
tracked_character_ids: tracked_character_ids,
|
||||
@@ -335,9 +337,8 @@ defmodule WandererAppWeb.MapCoreEventHandler do
|
||||
} = socket
|
||||
) do
|
||||
with {:ok, _} <- current_user |> WandererApp.Api.User.update_last_map(%{last_map_id: map_id}),
|
||||
{:ok, map_user_settings} <- WandererApp.MapUserSettingsRepo.get(map_id, current_user.id),
|
||||
{:ok, tracked_map_characters} <-
|
||||
MapCharactersEventHandler.get_tracked_map_characters(map_id, current_user),
|
||||
WandererApp.Maps.get_tracked_map_characters(map_id, current_user),
|
||||
{:ok, characters_limit} <- map_id |> WandererApp.Map.get_characters_limit(),
|
||||
{:ok, present_character_ids} <-
|
||||
WandererApp.Cache.lookup("map_#{map_id}:presence_character_ids", []),
|
||||
@@ -415,7 +416,6 @@ defmodule WandererAppWeb.MapCoreEventHandler do
|
||||
socket
|
||||
|> map_start(%{
|
||||
map_id: map_id,
|
||||
map_user_settings: map_user_settings,
|
||||
user_characters: user_character_eve_ids,
|
||||
initial_data: initial_data,
|
||||
events: events
|
||||
@@ -438,7 +438,6 @@ defmodule WandererAppWeb.MapCoreEventHandler do
|
||||
socket,
|
||||
%{
|
||||
map_id: map_id,
|
||||
map_user_settings: map_user_settings,
|
||||
user_characters: user_character_eve_ids,
|
||||
initial_data: initial_data,
|
||||
events: events
|
||||
@@ -469,7 +468,6 @@ defmodule WandererAppWeb.MapCoreEventHandler do
|
||||
socket
|
||||
|> assign(
|
||||
map_loaded?: true,
|
||||
map_user_settings: map_user_settings,
|
||||
user_characters: user_character_eve_ids,
|
||||
has_tracked_characters?: has_tracked_characters?
|
||||
)
|
||||
@@ -543,21 +541,6 @@ defmodule WandererAppWeb.MapCoreEventHandler do
|
||||
}
|
||||
end
|
||||
|
||||
defp get_tracked_map_characters(map_id, current_user) do
|
||||
case WandererApp.MapCharacterSettingsRepo.get_tracked_by_map_filtered(
|
||||
map_id,
|
||||
current_user.characters |> Enum.map(& &1.id)
|
||||
) do
|
||||
{:ok, settings} ->
|
||||
{:ok,
|
||||
settings
|
||||
|> Enum.map(fn s -> s |> Ash.load!(:character) |> Map.get(:character) end)}
|
||||
|
||||
_ ->
|
||||
{:ok, []}
|
||||
end
|
||||
end
|
||||
|
||||
defp filter_map_characters(
|
||||
characters,
|
||||
user_character_eve_ids,
|
||||
|
||||
@@ -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
|
||||
@@ -81,6 +81,7 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
|
||||
},
|
||||
%{
|
||||
assigns: %{
|
||||
current_user: current_user,
|
||||
map_id: map_id,
|
||||
map_user_settings: map_user_settings,
|
||||
user_characters: user_characters,
|
||||
@@ -129,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
|
||||
@@ -161,10 +162,40 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
|
||||
end)
|
||||
|
||||
added_signatures
|
||||
|> Enum.map(fn s ->
|
||||
|> Enum.each(fn s ->
|
||||
s |> WandererApp.Api.MapSystemSignature.create!()
|
||||
end)
|
||||
|
||||
added_signatures_eve_ids =
|
||||
added_signatures
|
||||
|> Enum.map(fn s -> s.eve_id end)
|
||||
|
||||
first_tracked_character =
|
||||
current_user.characters
|
||||
|> Enum.find(fn c -> c.eve_id === first_character_eve_id end)
|
||||
|
||||
if not is_nil(first_tracked_character) &&
|
||||
not (added_signatures_eve_ids |> Enum.empty?()) do
|
||||
WandererApp.User.ActivityTracker.track_map_event(:signatures_added, %{
|
||||
character_id: first_tracked_character.id,
|
||||
user_id: current_user.id,
|
||||
map_id: map_id,
|
||||
solar_system_id: system.solar_system_id,
|
||||
signatures: added_signatures_eve_ids
|
||||
})
|
||||
end
|
||||
|
||||
if not is_nil(first_tracked_character) &&
|
||||
not (removed_signatures_eve_ids |> Enum.empty?()) do
|
||||
WandererApp.User.ActivityTracker.track_map_event(:signatures_removed, %{
|
||||
character_id: first_tracked_character.id,
|
||||
user_id: current_user.id,
|
||||
map_id: map_id,
|
||||
solar_system_id: system.solar_system_id,
|
||||
signatures: removed_signatures_eve_ids
|
||||
})
|
||||
end
|
||||
|
||||
Phoenix.PubSub.broadcast!(WandererApp.PubSub, map_id, %{
|
||||
event: :signatures_updated,
|
||||
payload: system.solar_system_id
|
||||
|
||||
@@ -3,7 +3,6 @@ defmodule WandererAppWeb.MapStructuresEventHandler do
|
||||
use Phoenix.Component
|
||||
require Logger
|
||||
|
||||
alias WandererAppWeb.MapEventHandler
|
||||
alias WandererApp.Api.MapSystem
|
||||
alias WandererApp.Structure
|
||||
|
||||
|
||||
@@ -21,57 +21,63 @@ defmodule WandererAppWeb.MapSystemsEventHandler do
|
||||
|> MapEventHandler.push_map_event("remove_systems", solar_system_ids)
|
||||
|
||||
def handle_server_event(
|
||||
%{
|
||||
event: :maybe_select_system,
|
||||
payload: %{
|
||||
character_id: character_id,
|
||||
solar_system_id: solar_system_id
|
||||
}
|
||||
},
|
||||
%{assigns: %{current_user: current_user, map_id: map_id, map_user_settings: map_user_settings}} = socket
|
||||
) do
|
||||
%{
|
||||
event: :maybe_select_system,
|
||||
payload: %{
|
||||
character_id: character_id,
|
||||
solar_system_id: solar_system_id
|
||||
}
|
||||
},
|
||||
%{
|
||||
assigns: %{
|
||||
current_user: current_user,
|
||||
map_id: map_id,
|
||||
map_user_settings: map_user_settings
|
||||
}
|
||||
} = socket
|
||||
) do
|
||||
is_user_character =
|
||||
current_user.characters
|
||||
|> Enum.map(& &1.id)
|
||||
|> Enum.member?(character_id)
|
||||
|
||||
is_user_character =
|
||||
current_user.characters
|
||||
|> Enum.map(& &1.id)
|
||||
|> Enum.member?(character_id)
|
||||
is_select_on_spash =
|
||||
map_user_settings
|
||||
|> WandererApp.MapUserSettingsRepo.to_form_data!()
|
||||
|> WandererApp.MapUserSettingsRepo.get_boolean_setting("select_on_spash")
|
||||
|
||||
is_select_on_spash =
|
||||
map_user_settings
|
||||
|> WandererApp.MapUserSettingsRepo.to_form_data!()
|
||||
|> WandererApp.MapUserSettingsRepo.get_boolean_setting("select_on_spash")
|
||||
is_followed =
|
||||
case WandererApp.MapCharacterSettingsRepo.get_by_map(map_id, character_id) do
|
||||
{:ok, setting} -> setting.followed == true
|
||||
_ -> false
|
||||
end
|
||||
|
||||
is_followed =
|
||||
case WandererApp.MapCharacterSettingsRepo.get_by_map(map_id, character_id) do
|
||||
{:ok, setting} -> setting.followed == true
|
||||
_ -> false
|
||||
end
|
||||
must_select? = is_user_character && (is_select_on_spash || is_followed)
|
||||
|
||||
must_select? = is_user_character && (is_select_on_spash || is_followed)
|
||||
if not must_select? do
|
||||
if not must_select? do
|
||||
socket
|
||||
else
|
||||
# Check if we already selected this exact system for this char:
|
||||
last_selected =
|
||||
WandererApp.Cache.lookup!(
|
||||
"char:#{character_id}:map:#{map_id}:last_selected_system_id",
|
||||
nil
|
||||
)
|
||||
|
||||
if last_selected == solar_system_id do
|
||||
# same system => skip
|
||||
socket
|
||||
else
|
||||
# Check if we already selected this exact system for this char:
|
||||
last_selected =
|
||||
WandererApp.Cache.lookup!(
|
||||
"char:#{character_id}:map:#{map_id}:last_selected_system_id",
|
||||
nil
|
||||
)
|
||||
# new system => update cache + push event
|
||||
WandererApp.Cache.put(
|
||||
"char:#{character_id}:map:#{map_id}:last_selected_system_id",
|
||||
solar_system_id
|
||||
)
|
||||
|
||||
if last_selected == solar_system_id do
|
||||
# same system => skip
|
||||
socket
|
||||
else
|
||||
# new system => update cache + push event
|
||||
WandererApp.Cache.put(
|
||||
"char:#{character_id}:map:#{map_id}:last_selected_system_id",
|
||||
solar_system_id
|
||||
)
|
||||
|
||||
socket
|
||||
|> MapEventHandler.push_map_event("select_system", solar_system_id)
|
||||
end
|
||||
socket
|
||||
|> MapEventHandler.push_map_event("select_system", solar_system_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_server_event(%{event: :kills_updated, payload: kills}, socket) do
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user