Compare commits

...

40 Commits

Author SHA1 Message Date
CI
7073a0e8e6 chore: release version v1.45.2
Some checks are pending
Build / 🚀 Deploy to test env (fly.io) (push) Waiting to run
Build / 🛠 Build (1.17, 18.x, 27) (push) Waiting to run
Build / 🛠 Build Docker Images (linux/amd64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64/v8) (push) Blocked by required conditions
Build / merge (push) Blocked by required conditions
Build / 🏷 Create Release (push) Blocked by required conditions
2025-02-05 15:55:30 +00:00
guarzo
bb0d91a3c7 fix: fix route list hover and on the map character list (#149)
* fix: correct formatting for on the map character list

* fix: fix hover for route list
2025-02-05 19:55:05 +04:00
CI
1cb12b97ba chore: release version v1.45.1 2025-02-05 14:59:57 +00:00
guarzo
860d20dc66 fix: kill count subscript position on firefox, and remove kill filter for single system (#148) 2025-02-05 18:59:30 +04:00
CI
a850071965 chore: release version v1.45.0
Some checks are pending
Build / 🚀 Deploy to test env (fly.io) (push) Waiting to run
Build / 🛠 Build (1.17, 18.x, 27) (push) Waiting to run
Build / 🛠 Build Docker Images (linux/amd64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64/v8) (push) Blocked by required conditions
Build / merge (push) Blocked by required conditions
Build / 🏷 Create Release (push) Blocked by required conditions
2025-02-05 07:02:17 +00:00
guarzo
fc41573e70 feat: allow filtering of k-space kills (#147) 2025-02-05 07:01:46 +00:00
CI
97f1808fb5 chore: release version v1.44.9
Some checks are pending
Build / 🚀 Deploy to test env (fly.io) (push) Waiting to run
Build / 🛠 Build (1.17, 18.x, 27) (push) Waiting to run
Build / 🛠 Build Docker Images (linux/amd64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64/v8) (push) Blocked by required conditions
Build / merge (push) Blocked by required conditions
Build / 🏷 Create Release (push) Blocked by required conditions
2025-02-04 21:00:56 +00:00
guarzo
d31046eebb fix: improve local character header shrink behavior (#146) 2025-02-04 21:00:30 +00:00
CI
a70fa50eab chore: release version v1.44.8 2025-02-04 19:32:13 +00:00
Dmitry Popov
9a082c26f5 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-02-04 20:31:26 +01:00
Dmitry Popov
6af2dc1ed5 fix(Core): include external libraries in build 2025-02-04 20:31:21 +01:00
CI
5fd1509d44 chore: release version v1.44.7 2025-02-04 19:21:18 +00:00
Dmitry Popov
2448c0531b fix(Core): include external libraries in build 2025-02-04 20:20:34 +01:00
CI
b685ea1013 chore: release version v1.44.6 2025-02-04 17:19:49 +00:00
guarzo
55465688c8 Add hover tooltips for local counter and kills bookmark (#130)
* feat: add local pilots and kills display on hover
2025-02-04 21:19:13 +04:00
CI
ac3c7e0c44 chore: release version v1.44.5 2025-02-04 17:12:10 +00:00
guarzo
2d6ab5646c fix: include category param in search cache key (#144) 2025-02-04 21:11:41 +04:00
CI
67b373ac29 chore: release version v1.44.4
Some checks failed
Build / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build / merge (push) Has been cancelled
Build / 🏷 Create Release (push) Has been cancelled
2025-02-02 21:45:53 +00:00
Dmitry Popov
678169e6fa chore: release version v1.44.0 2025-02-02 22:44:55 +01:00
Dmitry Popov
7ee3c8db82 Merge branch 'main' into zkill_subs 2025-02-02 22:25:00 +01:00
Dmitry Popov
304f4b01ab chore: release version v1.44.0 2025-02-02 22:24:47 +01:00
CI
4af12c21b2 chore: release version v1.44.3 2025-02-02 21:18:47 +00:00
guarzo
497da1e5f7 fix: restored kills lightning bolt functionality (#143) 2025-02-03 01:18:21 +04:00
CI
5bd968acae chore: release version v1.44.2 2025-02-02 19:54:11 +00:00
guarzo
f74c20142c Add api for visible system kill information (#133)
* feat: api for zkill information
2025-02-02 23:53:40 +04:00
CI
d4c40d7542 chore: release version v1.44.1
Some checks are pending
Build / 🚀 Deploy to test env (fly.io) (push) Waiting to run
Build / 🛠 Build (1.17, 18.x, 27) (push) Waiting to run
Build / 🛠 Build Docker Images (linux/amd64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64) (push) Blocked by required conditions
Build / merge (push) Blocked by required conditions
Build / 🏷 Create Release (push) Blocked by required conditions
2025-02-01 20:34:37 +00:00
Aleksei Chichenkov
04f3fec0c0 fix(Map): Fixed problem with windows. (#140)
* fix(Map): Fixed problem with windows.

* fix(Core): Added min heigth for body

---------

Co-authored-by: achichenkov <aleksei.chichenkov@telleqt.ai>
Co-authored-by: Dmitry Popov <dmitriypopovsamara@gmail.com>
2025-02-02 00:34:08 +04:00
CI
cd0b4b0fc9 chore: release version v1.44.0 2025-02-01 15:22:11 +00:00
Aleksei Chichenkov
e7b115e6e6 Merge pull request #124 from guarzo/guarzo/zkill
feat: add zkill widget
2025-02-01 18:21:40 +03:00
Gustav
dff8fc6396 refactor: additional design feedback improvements 2025-01-31 15:00:46 -07:00
Gustav
afdaeb3d34 fix: design feedback patch 2025-01-31 15:00:46 -07:00
Gustav
ac6053361e fix: removed unneeded event handler 2025-01-31 15:00:46 -07:00
Gustav
eb3e1ba3aa feat: add news post for zkill widget 2025-01-31 15:00:46 -07:00
Gustav
8468a9b5de feat: add zkill widget 2025-01-31 15:00:46 -07:00
CI
5eafe59dcb chore: release version v1.43.9
Some checks failed
Build / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build / merge (push) Has been cancelled
Build / 🏷 Create Release (push) Has been cancelled
2025-01-30 21:55:35 +00:00
Dmitry Popov
b38bcaa8cf fix(Core): Add discord link to 'Like' icon on main interface 2025-01-30 22:55:04 +01:00
CI
8a238a447d chore: release version v1.43.8
Some checks failed
Build / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build / merge (push) Has been cancelled
Build / 🏷 Create Release (push) Has been cancelled
2025-01-26 16:21:18 +00:00
Dmitry Popov
3731219216 fix(Core): Update shuttered constellations (required EVE DB data update on server). 2025-01-26 17:20:28 +01:00
CI
73d5fd5f67 chore: release version v1.43.7 2025-01-26 16:12:32 +00:00
Dmitry Popov
e8e4aed6d5 Signature EOL status support (#136)
* feat(Map): Added an ability to mark signature as EOL

* chore: release version v1.39.1

* fix(Map): Add correct styles for switch

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

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

---------

Co-authored-by: achichenkov <aleksei.chichenkov@telleqt.ai>
2025-01-26 20:12:07 +04:00
122 changed files with 5391 additions and 1294 deletions

View File

@@ -7,3 +7,4 @@ export EVE_CLIENT_WITH_WALLET_SECRET="<EVE_CLIENT_WITH_WALLET_SECRET>"
export GIT_SHA="1111"
export WANDERER_INVITES="false"
export WANDERER_PUBLIC_API_DISABLED="false"
export WANDERER_ZKILL_PRELOAD_DISABLED="false"

View File

@@ -140,6 +140,7 @@ jobs:
platform:
- linux/amd64
- linux/arm64
- linux/arm64/v8
steps:
- name: Prepare
run: |

View File

@@ -2,6 +2,150 @@
<!-- changelog -->
## [v1.45.2](https://github.com/wanderer-industries/wanderer/compare/v1.45.1...v1.45.2) (2025-02-05)
### Bug Fixes:
* fix route list hover and on the map character list (#149)
* correct formatting for on the map character list
* fix hover for route list
## [v1.45.1](https://github.com/wanderer-industries/wanderer/compare/v1.45.0...v1.45.1) (2025-02-05)
### Bug Fixes:
* kill count subscript position on firefox, and remove kill filter for single system (#148)
## [v1.45.0](https://github.com/wanderer-industries/wanderer/compare/v1.44.9...v1.45.0) (2025-02-05)
### Features:
* allow filtering of k-space kills (#147)
## [v1.44.9](https://github.com/wanderer-industries/wanderer/compare/v1.44.8...v1.44.9) (2025-02-04)
### Bug Fixes:
* improve local character header shrink behavior (#146)
## [v1.44.8](https://github.com/wanderer-industries/wanderer/compare/v1.44.7...v1.44.8) (2025-02-04)
### Bug Fixes:
* Core: include external libraries in build
## [v1.44.7](https://github.com/wanderer-industries/wanderer/compare/v1.44.6...v1.44.7) (2025-02-04)
### Bug Fixes:
* Core: include external libraries in build
## [v1.44.6](https://github.com/wanderer-industries/wanderer/compare/v1.44.5...v1.44.6) (2025-02-04)
## [v1.44.5](https://github.com/wanderer-industries/wanderer/compare/v1.44.4...v1.44.5) (2025-02-04)
### Bug Fixes:
* include category param in search cache key (#144)
## [v1.44.4](https://github.com/wanderer-industries/wanderer/compare/v1.44.3...v1.44.4) (2025-02-02)
## [v1.44.3](https://github.com/wanderer-industries/wanderer/compare/v1.44.2...v1.44.3) (2025-02-02)
### Bug Fixes:
* restored kills lightning bolt functionality (#143)
## [v1.44.2](https://github.com/wanderer-industries/wanderer/compare/v1.44.1...v1.44.2) (2025-02-02)
## [v1.44.1](https://github.com/wanderer-industries/wanderer/compare/v1.44.0...v1.44.1) (2025-02-01)
### Bug Fixes:
* Map: Fixed problem with windows. (#140)
* Map: Fixed problem with windows.
* Core: Added min heigth for body
## [v1.44.0](https://github.com/wanderer-industries/wanderer/compare/v1.43.9...v1.44.0) (2025-02-01)
### Features:
* add news post for zkill widget
* add zkill widget
### Bug Fixes:
* design feedback patch
* removed unneeded event handler
## [v1.43.9](https://github.com/wanderer-industries/wanderer/compare/v1.43.8...v1.43.9) (2025-01-30)
### Bug Fixes:
* Core: Add discord link to 'Like' icon on main interface
## [v1.43.8](https://github.com/wanderer-industries/wanderer/compare/v1.43.7...v1.43.8) (2025-01-26)
### Bug Fixes:
* Core: Update shuttered constellations (required EVE DB data update on server).
## [v1.43.7](https://github.com/wanderer-industries/wanderer/compare/v1.43.6...v1.43.7) (2025-01-26)
## [v1.43.6](https://github.com/wanderer-industries/wanderer/compare/v1.43.5...v1.43.6) (2025-01-22)

View File

@@ -58,6 +58,7 @@ Now you can visit [`localhost:8000`](http://localhost:8000) from your browser.
- `root@0d0a785313b6:/app# apt update`
- `root@0d0a785313b6:/app# curl -sL https://deb.nodesource.com/setup_18.x | bash -`
- `root@0d0a785313b6:/app# apt-get install nodejs inotify-tools -y`
- `root@0d0a785313b6:/app# npm install -g yarn`
- `root@0d0a785313b6:/app# mix setup`
- See how to run server in #Run section

View File

@@ -1,6 +1,3 @@
// import '@fontsource-variable/inter'
// import '@fontsource-variable/jetbrains-mono'
// import './lib/tailwind/index.css';
import './css/app.css';
import './lib/phoenix';

View File

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

View File

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

View File

@@ -125,10 +125,9 @@ const MapComp = ({
const { handleRootContext, ...rootCtxProps } = useContextMenuRootHandlers({ onAddSystem });
const { handleConnectionContext, ...connectionCtxProps } = useContextMenuConnectionHandlers();
const { update } = useMapState();
const { variant, gap, size, color } = useBackgroundVars(theme);
const { variant, gap, size, color, snapSize } = useBackgroundVars(theme);
const { isPanAndDrag, nodeComponent, connectionMode } = getBehaviorForTheme(theme || 'default');
// You can create nodeTypes dynamically based on the node component
const nodeTypes = useMemo(() => {
return {
custom: nodeComponent,
@@ -256,7 +255,7 @@ const MapComp = ({
onEdgesChange(nextChanges);
},
[getEdge, getNode, onEdgesChange],
[canRemoveConnection, getEdge, getNode, onEdgesChange],
);
useEffect(() => {
@@ -283,6 +282,7 @@ const MapComp = ({
nodeTypes={nodeTypes}
connectionMode={connectionMode}
snapToGrid
snapGrid={[snapSize, snapSize]}
nodeDragThreshold={10}
onNodeDragStop={handleDragStop}
onSelectionDragStop={handleSelectionDragStop}

View File

@@ -1,6 +1,6 @@
import React, { createContext, useContext } from 'react';
import { OutCommandHandler } from '@/hooks/Mapper/types/mapHandlers.ts';
import { MapUnionTypes } from '@/hooks/Mapper/types';
import { MapUnionTypes, SystemSignature } from '@/hooks/Mapper/types';
import { ContextStoreDataUpdate, useContextStore } from '@/hooks/Mapper/utils';
export type MapData = MapUnionTypes & {
@@ -30,10 +30,14 @@ 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>,
is_subscription_active: false,
};
export interface MapContextProps {

View File

@@ -0,0 +1,48 @@
.KillsBookmark {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 8px;
font-weight: 700;
border: 0;
border-radius: 5px 5px 0 0;
padding: 4px 3px;
}
.KillsBookmarkWithIcon {
display: flex;
align-items: center;
justify-content: center;
margin-top: -2px;
text-shadow: 0 0 3px #000;
padding-right: 2px;
height: 8px;
font-size: 8px;
line-height: 12px;
font-weight: 700;
text-size-adjust: 100%;
.pi {
font-size: 9px;
}
.text {
font-size: 9px;
font-family: var(--rf-node-font-family, inherit) !important;
font-weight: var(--rf-node-font-weight, inherit) !important;
}
}
.TooltipContainer {
background-color: #1a1a1a;
color: #fff;
padding: 3px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border-radius: 2px;
pointer-events: auto;
max-width: 500px;
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
}

View File

@@ -0,0 +1,32 @@
import { SystemKillsContent } from '../../../mapInterface/widgets/SystemKills/SystemKillsContent/SystemKillsContent';
import { useKillsCounter } from '../../hooks/useKillsCounter';
import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper';
import { WithChildren, WithClassName } from '@/hooks/Mapper/types/common.ts';
type TooltipSize = 'xs' | 'sm' | 'md' | 'lg';
type KillsBookmarkTooltipProps = {
killsCount: number;
killsActivityType: string | null;
systemId: string;
className?: string;
size?: TooltipSize;
} & WithChildren &
WithClassName;
export const KillsCounter = ({ killsCount, systemId, className, children, size = 'xs' }: KillsBookmarkTooltipProps) => {
const { isLoading, kills: detailedKills, systemNameMap } = useKillsCounter({ realSystemId: systemId });
if (!killsCount || detailedKills.length === 0 || !systemId || isLoading) return null;
const tooltipContent = (
<SystemKillsContent kills={detailedKills} systemNameMap={systemNameMap} compact={true} onlyOneSystem={true} />
);
return (
// @ts-ignore
<WdTooltipWrapper content={tooltipContent} className={className} size={size} interactive={true}>
{children}
</WdTooltipWrapper>
);
};

View File

@@ -0,0 +1,64 @@
import { useMemo } from 'react';
import clsx from 'clsx';
import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper';
import { TooltipPosition } from '@/hooks/Mapper/components/ui-kit/WdTooltip';
import { LocalCharactersList, CharItemProps } from '../../../mapInterface/widgets/LocalCharacters/components';
import { useLocalCharactersItemTemplate } from '../../../mapInterface/widgets/LocalCharacters/hooks/useLocalCharacters';
import { useLocalCharacterWidgetSettings } from '../../../mapInterface/widgets/LocalCharacters/hooks/useLocalWidgetSettings';
interface LocalCounterProps {
localCounterCharacters: Array<CharItemProps>;
classes: { [key: string]: string };
hasUserCharacters: boolean;
showIcon?: boolean;
}
export function LocalCounter({
localCounterCharacters,
hasUserCharacters,
classes,
showIcon = true,
}: LocalCounterProps) {
const [settings] = useLocalCharacterWidgetSettings();
const itemTemplate = useLocalCharactersItemTemplate(settings.showShipName);
const pilotTooltipContent = useMemo(() => {
return (
<div
style={{
width: '300px',
overflowX: 'hidden',
overflowY: 'auto',
height: '300px',
}}
>
<LocalCharactersList items={localCounterCharacters} itemTemplate={itemTemplate} itemSize={26} />
</div>
);
}, [localCounterCharacters, itemTemplate]);
if (localCounterCharacters.length === 0) {
return null;
}
return (
<div className={classes.LocalCounterLayer} style={{ zIndex: 9999 }}>
<WdTooltipWrapper
// @ts-ignore
content={pilotTooltipContent}
position={TooltipPosition.right}
offset={180}
interactive={true}
>
<div
className={clsx(classes.localCounter, {
[classes.hasUserCharacters]: hasUserCharacters,
})}
>
{showIcon && <i className="pi pi-users" style={{ fontSize: '0.50rem' }} />}
<span>{localCounterCharacters.length}</span>
</div>
</WdTooltipWrapper>
</div>
);
}

View File

@@ -2,29 +2,25 @@
$pastel-blue: #5a7d9a;
$pastel-pink: #d291bc;
$pastel-green: #88b04b;
$pastel-yellow: #ffdd59;
$dark-bg: #2d2d2d;
$text-color: #ffffff;
$tooltip-bg: #202020;
$node-bg-color: #202020;
$node-soft-bg-color: #202020;
$text-color: #ffffff;
$tag-color: #38BDF8;
$region-name: #D6D3D1;
$custom-name: #93C5FD;
.RootCustomNode {
display: flex;
width: 130px;
height: 34px;
font-family: var(--rf-node-font-family, inherit) !important;
font-weight: var(--rf-node-font-weight, inherit) !important;
flex-direction: column;
padding: 2px 6px;
font-size: 10px;
background-color: $tooltip-bg;
background-color: var(--rf-node-bg-color, #202020) !important;
color: var(--rf-text-color, #ffffff);
box-shadow: 0 0 5px rgba($dark-bg, 0.5);
border: 1px solid darken($pastel-blue, 10%);
border-radius: 5px;
@@ -92,14 +88,6 @@ $custom-name: #93C5FD;
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(
@@ -178,8 +166,6 @@ $custom-name: #93C5FD;
padding-left: 3px;
padding-right: 3px;
//background-color: #833ca4;
&:not(:first-child) {
box-shadow: inset 4px -3px 4px rgba(0, 0, 0, 0.3);
}
@@ -266,26 +252,18 @@ $custom-name: #93C5FD;
.TagTitle {
font-size: 11px;
font-weight: medium;
font-weight: 500;
text-shadow: 0 0 2px rgba(231, 146, 52, 0.73);
color: var(--rf-tag-color, #38BDF8);
}
/* Firefox kostyl */
@-moz-document url-prefix() {
.classSystemName {
font-family: inherit !important;
font-weight: bold;
}
}
.classSystemName {
//font-weight: bold;
}
.solarSystemName {
}
}
.BottomRow {
@@ -294,22 +272,23 @@ $custom-name: #93C5FD;
align-items: center;
height: 19px;
.localCounter {
display: flex;
//align-items: center;
gap: 2px;
& > i {
position: relative;
top: 1px;
.hasLocalCounter {
margin-right: 1.25rem;
&.countAbove9 {
margin-right: 1.5rem;
}
}
& > span {
font-size: 9px;
line-height: 9px;
font-weight: 500;
//margin-top: 1px;
}
.lockIcon {
font-size: 0.45rem;
font-weight: bold;
position: relative;
}
.mapMarker {
font-size: 0.45rem;
font-weight: bold;
position: relative;
}
}
@@ -395,3 +374,39 @@ $custom-name: #93C5FD;
}
}
}
.LocalCounterLayer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
padding: 8;
.localCounter {
position: absolute;
pointer-events: auto;
top: 10.5px;
right: 8px;
mix-blend-mode: screen;
gap: 2px;
color: var(--rf-node-local-counter, #5cb85c);
&.hasUserCharacters {
color: var(--rf-has-user-characters, #fbbf24);
}
& > i {
position: relative;
top: 1px;
}
& > span {
font-size: 9px;
line-height: 9px;
font-weight: var(--rf-local-counter-font-weight, 500);
}
}
}

View File

@@ -1,20 +1,23 @@
import { memo } from 'react';
import { MapSolarSystemType } from '../../map.types';
import { Handle, Position, NodeProps } from 'reactflow';
import { Handle, NodeProps, Position } from 'reactflow';
import clsx from 'clsx';
import classes from './SolarSystemNodeDefault.module.scss';
import { PrimeIcons } from 'primereact/api';
import { useSolarSystemNode } from '../../hooks/useSolarSystemNode';
import { useLocalCounter, useSolarSystemNode } from '../../hooks/useSolarSystemLogic';
import {
EFFECT_BACKGROUND_STYLES,
MARKER_BOOKMARK_BG_STYLES,
STATUS_CLASSES,
EFFECT_BACKGROUND_STYLES,
} from '@/hooks/Mapper/components/map/constants';
import { WormholeClassComp } from '@/hooks/Mapper/components/map/components/WormholeClassComp';
import { UnsplashedSignature } from '@/hooks/Mapper/components/map/components/UnsplashedSignature';
import { LocalCounter } from './SolarSystemLocalCounter';
import { KillsCounter } from './SolarSystemKillsCounter';
export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>) => {
const nodeVars = useSolarSystemNode(props);
const { localCounterCharacters } = useLocalCounter(nodeVars);
return (
<>
@@ -22,7 +25,7 @@ export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>
<div className={classes.Bookmarks}>
{nodeVars.labelCustom !== '' && (
<div className={clsx(classes.Bookmark, MARKER_BOOKMARK_BG_STYLES.custom)}>
<span className="[text-shadow:_0_1px_0_rgb(0_0_0_/_40%)] ">{nodeVars.labelCustom}</span>
<span className="[text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]">{nodeVars.labelCustom}</span>
</div>
)}
@@ -32,13 +35,19 @@ export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>
</div>
)}
{nodeVars.killsCount && (
<div className={clsx(classes.Bookmark, MARKER_BOOKMARK_BG_STYLES[nodeVars.killsActivityType!])}>
{nodeVars.killsCount && nodeVars.killsCount > 0 && nodeVars.solarSystemId && (
<KillsCounter
killsCount={nodeVars.killsCount}
systemId={nodeVars.solarSystemId}
size="lg"
killsActivityType={nodeVars.killsActivityType}
className={clsx(classes.Bookmark, MARKER_BOOKMARK_BG_STYLES[nodeVars.killsActivityType!])}
>
<div className={clsx(classes.BookmarkWithIcon)}>
<span className={clsx(PrimeIcons.BOLT, classes.icon)} />
<span className={clsx(classes.text)}>{nodeVars.killsCount}</span>
</div>
</div>
</KillsCounter>
)}
{nodeVars.labelsInfo.map(x => (
@@ -53,10 +62,8 @@ export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>
className={clsx(
classes.RootCustomNode,
nodeVars.regionClass && classes[nodeVars.regionClass],
classes[STATUS_CLASSES[nodeVars.status]],
{
[classes.selected]: nodeVars.selected,
},
nodeVars.status !== undefined ? classes[STATUS_CLASSES[nodeVars.status]] : '',
{ [classes.selected]: nodeVars.selected },
)}
>
{nodeVars.visible && (
@@ -88,7 +95,7 @@ export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>
{nodeVars.isWormhole && (
<div className={classes.statics}>
{nodeVars.sortedStatics.map(whClass => (
<WormholeClassComp key={whClass} id={whClass} />
<WormholeClassComp key={String(whClass)} id={String(whClass)} />
))}
</div>
)}
@@ -114,24 +121,15 @@ export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>
{nodeVars.isWormhole && !nodeVars.customName && <div />}
<div className="flex items-center justify-end">
<div className="flex gap-1 items-center">
{nodeVars.locked && (
<i className={PrimeIcons.LOCK} style={{ fontSize: '0.45rem', fontWeight: 'bold' }} />
)}
{nodeVars.hubs.includes(nodeVars.solarSystemId.toString()) && (
<i className={PrimeIcons.MAP_MARKER} style={{ fontSize: '0.45rem', fontWeight: 'bold' }} />
)}
{nodeVars.charactersInSystem.length > 0 && (
<div
className={clsx(classes.localCounter, {
['text-amber-300']: nodeVars.hasUserCharacters,
})}
>
<i className="pi pi-users" style={{ fontSize: '0.50rem' }} />
<span className="font-sans">{nodeVars.charactersInSystem.length}</span>
</div>
<div
className={clsx('flex items-center gap-1', {
[classes.hasLocalCounter]: nodeVars.charactersInSystem.length > 0,
[classes.countAbove9]: nodeVars.charactersInSystem.length > 9,
})}
>
{nodeVars.locked && <i className={clsx(PrimeIcons.LOCK, classes.lockIcon)} />}
{nodeVars.hubs.includes(nodeVars.solarSystemId) && (
<i className={clsx(PrimeIcons.MAP_MARKER, classes.mapMarker)} />
)}
</div>
</div>
@@ -145,7 +143,7 @@ export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>
{nodeVars.unsplashedLeft.length > 0 && (
<div className={classes.Unsplashed}>
{nodeVars.unsplashedLeft.map(sig => (
<UnsplashedSignature key={sig.sig_id} signature={sig} />
<UnsplashedSignature key={sig.eve_id} signature={sig} />
))}
</div>
)}
@@ -153,14 +151,14 @@ export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>
{nodeVars.unsplashedRight.length > 0 && (
<div className={clsx(classes.Unsplashed, classes['Unsplashed--right'])}>
{nodeVars.unsplashedRight.map(sig => (
<UnsplashedSignature key={sig.sig_id} signature={sig} />
<UnsplashedSignature key={sig.eve_id} signature={sig} />
))}
</div>
)}
</>
)}
<div onMouseDownCapture={nodeVars.dbClick} className={classes.Handlers}>
<div onMouseDownCapture={e => nodeVars.dbClick(e)} className={classes.Handlers}>
<Handle
type="source"
className={clsx(classes.Handle, classes.HandleTop, {
@@ -202,6 +200,11 @@ export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>
id="d"
/>
</div>
<LocalCounter
hasUserCharacters={nodeVars.hasUserCharacters}
localCounterCharacters={localCounterCharacters}
classes={classes}
/>
</>
);
});

View File

@@ -1,91 +1,6 @@
@import './SolarSystemNodeDefault.module.scss';
/* ---------------------------
Only override what's different
--------------------------- */
/* 1) .RootCustomNode:
- new background-color using CSS var
- plus color, font-family, and font-weight */
.RootCustomNode {
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;
}
/* 2) .Bookmarks:
- add var-based font family/weight
*/
.Bookmarks {
font-family: var(--rf-node-font-family, inherit) !important;
font-weight: var(--rf-node-font-weight, inherit) !important;
}
/* 3) .HeadRow, .classTitle, .classSystemName:
- add new references to var-based font family/weight
*/
.HeadRow {
font-family: var(--rf-node-font-family, inherit) !important;
font-weight: var(--rf-node-font-weight, inherit) !important;
.classTitle {
font-family: var(--rf-node-font-family, inherit) !important;
font-weight: var(--rf-node-font-weight, inherit) !important;
}
@-moz-document url-prefix() {
.classSystemName {
font-family: var(--rf-node-font-family, inherit) !important;
font-weight: var(--rf-node-font-weight, inherit) !important;
}
}
.classSystemName {
font-family: var(--rf-node-font-family, inherit) !important;
font-weight: var(--rf-node-font-weight, inherit) !important;
}
}
/* 4) .BottomRow:
- introduces .tagTitle, .regionName, .customName, .localCounter
referencing new CSS variables */
.BottomRow {
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;
text-shadow: 0 0 2px rgba(231, 146, 52, 0.73);
font-family: var(--rf-node-font-family, inherit) !important;
font-weight: var(--rf-node-font-weight, inherit) !important;
color: var(--rf-tag-color, #38BDF8);
}
.regionName {
color: var(--rf-region-name, #D6D3D1);
font-family: var(--rf-node-font-family, inherit) !important;
font-weight: var(--rf-node-font-weight, inherit) !important;
}
.customName {
color: var(--rf-custom-name, #93C5FD);
font-family: var(--rf-node-font-family, inherit) !important;
font-weight: var(--rf-node-font-weight, inherit) !important;
}
.localCounter {
display: flex;
color: var(--rf-has-user-characters, #fbbf24);
font-family: var(--rf-node-font-family, inherit) !important;
font-weight: var(--rf-node-font-weight, inherit) !important;
gap: 2px;
.hasUserCharacters {
color: var(--rf-has-user-characters, #fbbf24);
font-family: var(--rf-node-font-family, inherit) !important;
font-weight: var(--rf-node-font-weight, inherit) !important;
}
}
}
/* ---------------------------------------------
Only override what's different from the base
Currently none required
---------------------------------------------- */

View File

@@ -1,20 +1,23 @@
import { memo } from 'react';
import { MapSolarSystemType } from '../../map.types';
import { Handle, Position, NodeProps } from 'reactflow';
import { Handle, NodeProps, Position } from 'reactflow';
import clsx from 'clsx';
import classes from './SolarSystemNodeTheme.module.scss';
import { PrimeIcons } from 'primereact/api';
import { useSolarSystemNode } from '../../hooks/useSolarSystemNode';
import { useLocalCounter, useSolarSystemNode } from '../../hooks/useSolarSystemLogic';
import {
EFFECT_BACKGROUND_STYLES,
MARKER_BOOKMARK_BG_STYLES,
STATUS_CLASSES,
EFFECT_BACKGROUND_STYLES,
} from '@/hooks/Mapper/components/map/constants';
import { WormholeClassComp } from '@/hooks/Mapper/components/map/components/WormholeClassComp';
import { UnsplashedSignature } from '@/hooks/Mapper/components/map/components/UnsplashedSignature';
import { LocalCounter } from './SolarSystemLocalCounter';
import { KillsCounter } from './SolarSystemKillsCounter';
export const SolarSystemNodeTheme = memo((props: NodeProps<MapSolarSystemType>) => {
const nodeVars = useSolarSystemNode(props);
const { localCounterCharacters } = useLocalCounter(nodeVars);
return (
<>
@@ -32,13 +35,19 @@ export const SolarSystemNodeTheme = memo((props: NodeProps<MapSolarSystemType>)
</div>
)}
{nodeVars.killsCount && (
<div className={clsx(classes.Bookmark, MARKER_BOOKMARK_BG_STYLES[nodeVars.killsActivityType!])}>
{nodeVars.killsCount && nodeVars.killsCount > 0 && nodeVars.solarSystemId && (
<KillsCounter
killsCount={nodeVars.killsCount}
systemId={nodeVars.solarSystemId}
size="lg"
killsActivityType={nodeVars.killsActivityType}
className={clsx(classes.Bookmark, MARKER_BOOKMARK_BG_STYLES[nodeVars.killsActivityType!])}
>
<div className={clsx(classes.BookmarkWithIcon)}>
<span className={clsx(PrimeIcons.BOLT, classes.icon)} />
<span className={clsx(classes.text)}>{nodeVars.killsCount}</span>
</div>
</div>
</KillsCounter>
)}
{nodeVars.labelsInfo.map(x => (
@@ -53,10 +62,8 @@ export const SolarSystemNodeTheme = memo((props: NodeProps<MapSolarSystemType>)
className={clsx(
classes.RootCustomNode,
nodeVars.regionClass && classes[nodeVars.regionClass],
classes[STATUS_CLASSES[nodeVars.status]],
{
[classes.selected]: nodeVars.selected,
},
nodeVars.status !== undefined ? classes[STATUS_CLASSES[nodeVars.status]] : '',
{ [classes.selected]: nodeVars.selected },
)}
>
{nodeVars.visible && (
@@ -88,7 +95,7 @@ export const SolarSystemNodeTheme = memo((props: NodeProps<MapSolarSystemType>)
{nodeVars.isWormhole && (
<div className={classes.statics}>
{nodeVars.sortedStatics.map(whClass => (
<WormholeClassComp key={whClass} id={whClass} />
<WormholeClassComp key={String(whClass)} id={String(whClass)} />
))}
</div>
)}
@@ -124,24 +131,15 @@ export const SolarSystemNodeTheme = memo((props: NodeProps<MapSolarSystemType>)
{nodeVars.isWormhole && !nodeVars.customName && <div />}
<div className="flex items-center justify-end">
<div className="flex gap-1 items-center">
{nodeVars.locked && (
<i className={PrimeIcons.LOCK} style={{ fontSize: '0.45rem', fontWeight: 'bold' }} />
)}
{nodeVars.hubs.includes(nodeVars.solarSystemId.toString()) && (
<i className={PrimeIcons.MAP_MARKER} style={{ fontSize: '0.45rem', fontWeight: 'bold' }} />
)}
{nodeVars.charactersInSystem.length > 0 && (
<div
className={clsx(classes.localCounter, {
[classes.hasUserCharacters]: nodeVars.hasUserCharacters,
})}
>
<i className="pi pi-users" style={{ fontSize: '0.50rem' }} />
<span className="font-sans">{nodeVars.charactersInSystem.length}</span>
</div>
<div
className={clsx('flex items-center gap-1', {
[classes.hasLocalCounter]: nodeVars.charactersInSystem.length > 0,
[classes.countAbove9]: nodeVars.charactersInSystem.length > 9,
})}
>
{nodeVars.locked && <i className={clsx(PrimeIcons.LOCK, classes.lockIcon)} />}
{nodeVars.hubs.includes(nodeVars.solarSystemId) && (
<i className={clsx(PrimeIcons.MAP_MARKER, classes.mapMarker)} />
)}
</div>
</div>
@@ -155,7 +153,7 @@ export const SolarSystemNodeTheme = memo((props: NodeProps<MapSolarSystemType>)
{nodeVars.unsplashedLeft.length > 0 && (
<div className={classes.Unsplashed}>
{nodeVars.unsplashedLeft.map(sig => (
<UnsplashedSignature key={sig.sig_id} signature={sig} />
<UnsplashedSignature key={sig.eve_id} signature={sig} />
))}
</div>
)}
@@ -163,14 +161,14 @@ export const SolarSystemNodeTheme = memo((props: NodeProps<MapSolarSystemType>)
{nodeVars.unsplashedRight.length > 0 && (
<div className={clsx(classes.Unsplashed, classes['Unsplashed--right'])}>
{nodeVars.unsplashedRight.map(sig => (
<UnsplashedSignature key={sig.sig_id} signature={sig} />
<UnsplashedSignature key={sig.eve_id} signature={sig} />
))}
</div>
)}
</>
)}
<div onMouseDownCapture={nodeVars.dbClick} className={classes.Handlers}>
<div onMouseDownCapture={e => nodeVars.dbClick(e)} className={classes.Handlers}>
<Handle
type="source"
className={clsx(classes.Handle, classes.HandleTop, {
@@ -212,6 +210,11 @@ export const SolarSystemNodeTheme = memo((props: NodeProps<MapSolarSystemType>)
id="d"
/>
</div>
<LocalCounter
hasUserCharacters={nodeVars.hasUserCharacters}
localCounterCharacters={localCounterCharacters}
classes={classes}
/>
</>
);
});

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ export function useBackgroundVars(themeName?: string) {
const [gap, setGap] = useState<number>(16);
const [size, setSize] = useState<number>(1);
const [color, setColor] = useState('#81818b');
const [snapSize, setSnapSize] = useState<number>(25);
useEffect(() => {
// match any element whose entire `class` attribute ends with "-theme"
@@ -29,16 +30,19 @@ export function useBackgroundVars(themeName?: string) {
const cssVarGap = style.getPropertyValue('--rf-bg-gap');
const cssVarSize = style.getPropertyValue('--rf-bg-size');
const cssVarSnapSize = style.getPropertyValue('--rf-snap-size');
const cssColor = style.getPropertyValue('--rf-bg-pattern-color');
const gapNum = parseInt(cssVarGap, 10) || 16;
const sizeNum = parseInt(cssVarSize, 10) || 1;
const snapSize = parseInt(cssVarSnapSize, 10) || 25; //react-flow default
setVariant(finalVariant);
setGap(gapNum);
setSize(sizeNum);
setColor(cssColor);
setSnapSize(snapSize);
}, [themeName]);
return { variant, gap, size, color };
return { variant, gap, size, color, snapSize };
}

View File

@@ -0,0 +1,44 @@
import { useMemo } from 'react';
import { useSystemKills } from '../../mapInterface/widgets/SystemKills/hooks/useSystemKills';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
interface UseKillsCounterProps {
realSystemId: string;
}
export function useKillsCounter({ realSystemId }: UseKillsCounterProps) {
const { data: mapData, outCommand } = useMapRootState();
const { systems } = mapData;
const systemNameMap = useMemo(() => {
const m: Record<string, string> = {};
systems.forEach(sys => {
m[sys.id] = sys.temporary_name || sys.name || '???';
});
return m;
}, [systems]);
const { kills: allKills, isLoading } = useSystemKills({
systemId: realSystemId,
outCommand,
showAllVisible: false,
});
const filteredKills = useMemo(() => {
if (!allKills || allKills.length === 0) return [];
return [...allKills]
.sort((a, b) => {
const aTime = a.kill_time ? new Date(a.kill_time).getTime() : 0;
const bTime = b.kill_time ? new Date(b.kill_time).getTime() : 0;
return bTime - aTime;
})
.slice(0, 10);
}, [allKills]);
return {
isLoading,
kills: filteredKills,
systemNameMap,
};
}

View File

@@ -127,6 +127,10 @@ export const useMapHandlers = (ref: ForwardedRef<MapHandlers>, onSelectionChange
// do nothing here
break;
case Commands.detailedKillsUpdated:
// do nothing here
break;
default:
console.warn(`Map handlers: Unknown command: ${type}`, data);
break;

View File

@@ -10,10 +10,17 @@ 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 { CharacterTypeRaw, OutCommand } from '@/hooks/Mapper/types';
import { CharacterTypeRaw, OutCommand, SystemSignature } from '@/hooks/Mapper/types';
import { LABELS_INFO, LABELS_ORDER } from '@/hooks/Mapper/components/map/constants';
function getActivityType(count: number) {
export type LabelInfo = {
id: string;
shortName: string;
};
export type UnsplashedSignatureType = SystemSignature & { sig_id: string };
function getActivityType(count: number): string {
if (count <= 5) return 'activityNormal';
if (count <= 30) return 'activityWarn';
return 'activityDanger';
@@ -26,12 +33,25 @@ const SpaceToClass: Record<string, string> = {
[Spaces.Gallente]: 'Gallente',
};
function sortedLabels(labels: string[]) {
function sortedLabels(labels: string[]): LabelInfo[] {
if (!labels) return [];
return LABELS_ORDER.filter(x => labels.includes(x)).map(x => LABELS_INFO[x]);
return LABELS_ORDER.filter(x => labels.includes(x)).map(x => LABELS_INFO[x] as LabelInfo);
}
export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>) {
export function useLocalCounter(nodeVars: SolarSystemNodeVars) {
const localCounterCharacters = useMemo(() => {
return nodeVars.charactersInSystem
.map(char => ({
...char,
compact: true,
isOwn: nodeVars.userCharacters.includes(char.eve_id),
}))
.sort((a, b) => a.name.localeCompare(b.name));
}, [nodeVars.charactersInSystem, nodeVars.userCharacters]);
return { localCounterCharacters };
}
export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>): SolarSystemNodeVars {
const { id, data, selected } = props;
const {
system_static_info,
@@ -71,7 +91,6 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>) {
const {
data: {
characters,
presentCharacters,
wormholesData,
hubs,
kills,
@@ -87,15 +106,14 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>) {
const visible = useMemo(() => visibleNodes.has(id), [id, visibleNodes]);
const systemSignatures = useMemo(
const systemSigs = useMemo(
() => mapSystemSignatures[solar_system_id] || system_signatures,
[system_signatures, solar_system_id, mapSystemSignatures],
);
const charactersInSystem = useMemo(() => {
return characters.filter(c => c.location?.solar_system_id === solar_system_id).filter(c => c.online);
// eslint-disable-next-line
}, [characters, presentCharacters, solar_system_id]);
return characters.filter(c => c.location?.solar_system_id === solar_system_id && c.online);
}, [characters, solar_system_id]);
const isWormhole = isWormholeSpace(system_class);
@@ -136,52 +154,65 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>) {
const space = showKSpaceBG ? REGIONS_MAP[region_id] : '';
const regionClass = showKSpaceBG ? SpaceToClass[space] : null;
const temporaryName = useMemo(() => {
const computedTemporaryName = useMemo(() => {
if (!isTempSystemNameEnabled) {
return '';
}
if (isShowLinkedSigIdTempName && linkedSigPrefix) {
return temporary_name ? `${linkedSigPrefix}${temporary_name}` : `${linkedSigPrefix}${solar_system_name}`;
}
return temporary_name;
}, [isShowLinkedSigIdTempName, isTempSystemNameEnabled, linkedSigPrefix, solar_system_name, temporary_name]);
const systemName = useMemo(() => {
if (isTempSystemNameEnabled && temporaryName) {
return temporaryName;
if (isTempSystemNameEnabled && computedTemporaryName) {
return computedTemporaryName;
}
return solar_system_name;
}, [isTempSystemNameEnabled, solar_system_name, temporaryName]);
}, [isTempSystemNameEnabled, solar_system_name, computedTemporaryName]);
const customName = (isTempSystemNameEnabled && temporaryName && name) || (solar_system_name !== name && name) || null;
const customName = useMemo(() => {
if (isTempSystemNameEnabled && computedTemporaryName && name) {
return name;
}
if (solar_system_name !== name && name) {
return name;
}
return null;
}, [isTempSystemNameEnabled, computedTemporaryName, name, solar_system_name]);
const [unsplashedLeft, unsplashedRight] = useMemo(() => {
if (!isShowUnsplashedSignatures) {
return [[], []];
}
return prepareUnsplashedChunks(
systemSignatures
systemSigs
.filter(s => s.group === 'Wormhole' && !s.linked_system)
.map(s => ({
eve_id: s.eve_id,
type: s.type,
custom_info: s.custom_info,
})),
kind: s.kind,
name: s.name,
group: s.group,
sig_id: s.eve_id, // Add a unique key property
})) as UnsplashedSignatureType[],
);
}, [isShowUnsplashedSignatures, systemSignatures]);
}, [isShowUnsplashedSignatures, systemSigs]);
const nodeVars = {
// Ensure hubs are always strings.
const hubsAsStrings = useMemo(() => hubs.map(item => item.toString()), [hubs]);
const nodeVars: SolarSystemNodeVars = {
id,
selected,
visible,
isWormhole,
classTitleColor,
killsCount,
killsActivityType,
hasUserCharacters,
userCharacters,
showHandlers,
regionClass,
systemName,
@@ -195,10 +226,10 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>) {
sortedStatics,
effectName: effect_name,
regionName: region_name,
solarSystemId: solar_system_id,
solarSystemId: solar_system_id.toString(),
solarSystemName: solar_system_name,
locked,
hubs,
hubs: hubsAsStrings,
name: name,
isConnecting,
hoverNodeId,
@@ -207,7 +238,7 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>) {
unsplashedRight,
isThickConnections,
classTitle: class_title,
temporaryName: temporary_name,
temporaryName: computedTemporaryName,
};
return nodeVars;
@@ -230,24 +261,22 @@ export interface SolarSystemNodeVars {
isShattered: boolean;
tag?: string | null;
status?: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
labelsInfo: Array<any>;
dbClick: (event?: void) => void;
labelsInfo: LabelInfo[];
dbClick: (event: React.MouseEvent<HTMLDivElement>) => void;
sortedStatics: Array<string | number>;
effectName: string | null;
regionName: string | null;
solarSystemId: number;
solarSystemId: string;
solarSystemName: string | null;
locked: boolean;
hubs: string[] | number[];
hubs: string[];
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>;
userCharacters: string[];
unsplashedLeft: Array<SystemSignature>;
unsplashedRight: Array<SystemSignature>;
isThickConnections: boolean;
classTitle: string | null;
temporaryName?: string | null;

View File

@@ -11,7 +11,8 @@
--rf-tag-color: #38BDF8;
--rf-region-name: #D6D3D1;
--rf-custom-name: #93C5FD;
--rf-node-font-family: 'Shentox', 'Rogan', sans-serif !important;
--rf-node-font-weight: 500;
--rf-bg-variant: "dots";
--rf-bg-gap: 15;
@@ -28,4 +29,8 @@
--tooltip-bg: #202020;
--window-corner: #72716f;
--rf-local-counter-font-weight: 500;
--rf-node-local-counter: #5cb85c;
--rf-has-user-characters: #fbbf24;
}

View File

@@ -3,23 +3,26 @@
@import url('https://fonts.googleapis.com/css2?family=Oxygen:wght@300;400;700&display=swap');
.pathfinder-theme {
/* -- Override values from the default theme -- */
--rf-bg-color: #000000;
--rf-soft-bg-color: #282828;
--rf-node-bg-color: #202020;
--rf-node-soft-bg-color: #313335;
--rf-node-font-weight: bold;
--rf-text-color: #adadad;
--rf-region-name: var(--rf-text-color);
--rf-custom-name: var(--rf-text-color);
--tooltip-bg: #202020;
--rf-bg-variant: "lines";
--rf-bg-gap: 32;
--rf-bg-size: 1;
--rf-bg-gap: 34;
--rf-snap-size: 17;
--rf-bg-pattern-color: #313131;
--rf-local-counter-font-weight: 700;
/* Additional node-specific overrides */
--rf-node-line-height: normal;
--rf-node-font-family: 'Oxygen', sans-serif;
--rf-tag-color: #fbbf24;
/* -- theme-specific variables -- */
--eve-effect-pulsar: #428bca;
--eve-effect-magnetar: #e06fdf;
--eve-effect-wolfRayet: #e28a0d;
@@ -38,14 +41,4 @@
--eve-wh-type-color-c6: #d9534f;
--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;
--rf-node-text-color: var(--pf-text-color);
--rf-tag-color: #fbbf24;
--rf-has-user-characters: #5cb85c;
--window-corner: #72716f;
}

View File

@@ -1,5 +1,3 @@
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { useMemo } from 'react';
import { WindowManager } from '@/hooks/Mapper/components/ui-kit/WindowManager';
import { DEFAULT_WIDGETS } from '@/hooks/Mapper/components/mapInterface/constants.tsx';
@@ -21,5 +19,12 @@ export const MapInterface = () => {
.filter(x => windowsSettings.visible.some(j => x.id === j));
}, [windowsSettings]);
return <WindowManager windows={items} dragSelector=".react-grid-dragHandleExample" onChange={updateWidgetSettings} />;
return (
<WindowManager
windows={items}
viewPort={windowsSettings.viewPort}
dragSelector=".react-grid-dragHandleExample"
onChange={updateWidgetSettings}
/>
);
};

View File

@@ -5,6 +5,7 @@ import {
SystemInfo,
SystemSignatures,
SystemStructures,
SystemKills,
} from '@/hooks/Mapper/components/mapInterface/widgets';
export const CURRENT_WINDOWS_VERSION = 8;
@@ -16,6 +17,7 @@ export enum WidgetsIds {
local = 'local',
routes = 'routes',
structures = 'structures',
kills = 'kills',
}
export const STORED_VISIBLE_WIDGETS_DEFAULT = [
@@ -61,6 +63,13 @@ export const DEFAULT_WIDGETS: WindowProps[] = [
zIndex: 0,
content: () => <SystemStructures />,
},
{
id: WidgetsIds.kills,
position: { x: 270, y: 730 },
size: { width: 510, height: 200 },
zIndex: 0,
content: () => <SystemKills />,
},
];
type WidgetsCheckboxesType = {
@@ -89,4 +98,8 @@ export const WIDGETS_CHECKBOXES_PROPS: WidgetsCheckboxesType = [
id: WidgetsIds.structures,
label: 'Structures',
},
{
id: WidgetsIds.kills,
label: 'Kills',
},
];

View File

@@ -1,71 +1,25 @@
import { useCallback, useMemo, useRef } from 'react';
import { useMemo, useRef } from 'react';
import { Widget } from '@/hooks/Mapper/components/mapInterface/components';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { VirtualScroller, VirtualScrollerTemplateOptions } from 'primereact/virtualscroller';
import clsx from 'clsx';
import classes from './LocalCharacters.module.scss';
import { CharacterTypeRaw, WithIsOwnCharacter } from '@/hooks/Mapper/types';
import { CharacterCard, LayoutEventBlocker, WdCheckbox } from '@/hooks/Mapper/components/ui-kit';
import { LayoutEventBlocker, WdCheckbox } from '@/hooks/Mapper/components/ui-kit';
import { sortCharacters } from '@/hooks/Mapper/components/mapInterface/helpers/sortCharacters.ts';
import useLocalStorageState from 'use-local-storage-state';
import { useMapCheckPermissions, useMapGetOption } from '@/hooks/Mapper/mapRootProvider/hooks/api';
import { UserPermission } from '@/hooks/Mapper/types/permissions.ts';
import useMaxWidth from '@/hooks/Mapper/hooks/useMaxWidth.ts';
import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper';
type CharItemProps = {
compact: boolean;
} & CharacterTypeRaw &
WithIsOwnCharacter;
const useItemTemplate = () => {
const {
data: { presentCharacters },
} = useMapRootState();
return useCallback(
(char: CharItemProps, options: VirtualScrollerTemplateOptions) => {
return (
<div
className={clsx(classes.CharacterRow, 'w-full box-border', {
'surface-hover': options.odd,
['border-b border-gray-600 border-opacity-20']: !options.last,
['bg-green-500 hover:bg-green-700 transition duration-300 bg-opacity-10 hover:bg-opacity-10']: char.online,
})}
style={{ height: options.props.itemSize + 'px' }}
>
<CharacterCard showShipName {...char} />
</div>
);
},
// eslint-disable-next-line
[presentCharacters],
);
};
type WindowLocalSettingsType = {
compact: boolean;
showOffline: boolean;
version: number;
};
const STORED_DEFAULT_VALUES: WindowLocalSettingsType = {
compact: true,
showOffline: false,
version: 0,
};
import { LocalCharactersList } from './components/LocalCharactersList';
import { useLocalCharactersItemTemplate } from './hooks/useLocalCharacters';
import { useLocalCharacterWidgetSettings } from './hooks/useLocalWidgetSettings';
export const LocalCharacters = () => {
const {
data: { characters, userCharacters, selectedSystems, presentCharacters },
data: { characters, userCharacters, selectedSystems },
} = useMapRootState();
const [settings, setSettings] = useLocalStorageState<WindowLocalSettingsType>('window:local:settings', {
defaultValue: STORED_DEFAULT_VALUES,
});
const [settings, setSettings] = useLocalCharacterWidgetSettings();
const [systemId] = selectedSystems;
const restrictOfflineShowing = useMapGetOption('restrict_offline_showing');
const isAdminOrManager = useMapCheckPermissions([UserPermission.MANAGE_MAP]);
@@ -74,21 +28,31 @@ export const LocalCharacters = () => {
[isAdminOrManager, restrictOfflineShowing],
);
const itemTemplate = useItemTemplate();
const sorted = useMemo(() => {
const sorted = characters
const filtered = characters
.filter(x => x.location?.solar_system_id?.toString() === systemId)
.map(x => ({ ...x, isOwn: userCharacters.includes(x.eve_id), compact: settings.compact }))
.map(x => ({
...x,
isOwn: userCharacters.includes(x.eve_id),
compact: settings.compact,
showShipName: settings.showShipName,
}))
.sort(sortCharacters);
if (!showOffline || !settings.showOffline) {
return sorted.filter(c => c.online);
return filtered.filter(c => c.online);
}
return sorted;
// eslint-disable-next-line
}, [showOffline, characters, settings.showOffline, settings.compact, systemId, userCharacters, presentCharacters]);
return filtered;
}, [
characters,
systemId,
userCharacters,
settings.compact,
settings.showOffline,
settings.showShipName,
showOffline,
]);
const isNobodyHere = sorted.length === 0;
const isNotSelectedSystem = selectedSystems.length !== 1;
@@ -97,33 +61,60 @@ export const LocalCharacters = () => {
const ref = useRef<HTMLDivElement>(null);
const compact = useMaxWidth(ref, 145);
const itemTemplate = useLocalCharactersItemTemplate(settings.showShipName);
return (
<Widget
label={
<div className="flex justify-between items-center text-xs w-full" ref={ref}>
<span className="select-none">Local{showList ? ` [${sorted.length}]` : ''}</span>
<LayoutEventBlocker className="flex items-center gap-2">
{showOffline && (
<WdTooltipWrapper content="Show offline characters in system">
<WdCheckbox
size="xs"
labelSide="left"
label={compact ? '' : 'Show offline'}
value={settings.showOffline}
classNameLabel="text-stone-400 hover:text-stone-200 transition duration-300"
onChange={() => setSettings(() => ({ ...settings, showOffline: !settings.showOffline }))}
/>
</WdTooltipWrapper>
)}
<div className="flex w-full items-center" ref={ref}>
<div className="flex-shrink-0 select-none mr-2">
Local{showList ? ` [${sorted.length}]` : ''}
</div>
<div className="flex-grow overflow-hidden">
<LayoutEventBlocker className="flex items-center gap-2 justify-end">
{showOffline && (
<WdTooltipWrapper content="Show offline characters in system">
<div className={clsx("min-w-0", { "max-w-[100px]": compact })}>
<WdCheckbox
size="xs"
labelSide="left"
label="Show offline"
value={settings.showOffline}
classNameLabel={clsx("whitespace-nowrap", { "truncate": compact })}
onChange={() =>
setSettings(prev => ({ ...prev, showOffline: !prev.showOffline }))
}
/>
</div>
</WdTooltipWrapper>
)}
<span
className={clsx('w-4 h-4 cursor-pointer', {
['hero-bars-2']: settings.compact,
['hero-bars-3']: !settings.compact,
})}
onClick={() => setSettings(() => ({ ...settings, compact: !settings.compact }))}
></span>
</LayoutEventBlocker>
{settings.compact && (
<WdTooltipWrapper content="Show ship name in compact rows">
<div className={clsx("min-w-0", { "max-w-[100px]": compact })}>
<WdCheckbox
size="xs"
labelSide="left"
label="Show ship name"
value={settings.showShipName}
classNameLabel={clsx("whitespace-nowrap", { "truncate": compact })}
onChange={() =>
setSettings(prev => ({ ...prev, showShipName: !prev.showShipName }))
}
/>
</div>
</WdTooltipWrapper>
)}
<span
className={clsx("w-4 h-4 cursor-pointer", {
"hero-bars-2": settings.compact,
"hero-bars-3": !settings.compact,
})}
onClick={() => setSettings(prev => ({ ...prev, compact: !prev.compact }))}
/>
</LayoutEventBlocker>
</div>
</div>
}
>
@@ -140,15 +131,11 @@ export const LocalCharacters = () => {
)}
{showList && (
<VirtualScroller
<LocalCharactersList
items={sorted}
itemSize={settings.compact ? 26 : 41}
itemTemplate={itemTemplate}
className={clsx(
classes.VirtualScroller,
'w-full h-full overflow-x-hidden overflow-y-auto custom-scrollbar select-none',
)}
autoSize={false}
containerClassName="w-full h-full overflow-x-hidden overflow-y-auto"
/>
)}
</Widget>

View File

@@ -0,0 +1,4 @@
// .VirtualScroller {
// height: 100% !important;
// }

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { VirtualScroller, VirtualScrollerTemplateOptions } from 'primereact/virtualscroller';
import clsx from 'clsx';
import { CharItemProps } from './types';
type LocalCharactersListProps = {
items: Array<CharItemProps>;
itemSize: number;
itemTemplate: (char: CharItemProps, options: VirtualScrollerTemplateOptions) => React.ReactNode;
containerClassName?: string;
};
export function LocalCharactersList({ items, itemSize, itemTemplate, containerClassName }: LocalCharactersListProps) {
return (
<VirtualScroller
items={items}
itemSize={itemSize}
orientation="vertical"
className={clsx('w-full h-full', containerClassName)}
autoSize={false}
itemTemplate={itemTemplate}
/>
);
}

View File

@@ -0,0 +1,2 @@
export * from './LocalCharactersList';
export * from './types';

View File

@@ -0,0 +1,6 @@
import { CharacterTypeRaw, WithIsOwnCharacter } from '@/hooks/Mapper/types';
export type CharItemProps = {
compact: boolean;
} & CharacterTypeRaw &
WithIsOwnCharacter;

View File

@@ -0,0 +1,33 @@
import { useCallback } from 'react';
import { VirtualScrollerTemplateOptions } from 'primereact/virtualscroller';
import clsx from 'clsx';
import classes from './useLocalCharacters.module.scss';
import { CharacterCard } from '@/hooks/Mapper/components/ui-kit';
import { CharItemProps } from '../components';
export function useLocalCharactersItemTemplate(showShipName: boolean) {
return useCallback(
(char: CharItemProps, options: VirtualScrollerTemplateOptions) => {
return (
<div
className={clsx(classes.CharacterRow, 'box-border flex items-center', {
'surface-hover': options.odd,
'border-b border-gray-600 border-opacity-20': !options.last,
'bg-green-500 hover:bg-green-700 transition duration-300 bg-opacity-10 hover:bg-opacity-10': char.online,
})}
style={{
height: `${options.props.itemSize}px`,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: 0,
width: '100%',
}}
>
<CharacterCard showShipName={showShipName} {...char} />
</div>
);
},
[showShipName],
);
}

View File

@@ -0,0 +1,21 @@
import useLocalStorageState from 'use-local-storage-state';
export interface LocalCharacterWidgetSettings {
compact: boolean;
showOffline: boolean;
version: number;
showShipName: boolean;
}
export const LOCAL_CHARACTER_WIDGET_DEFAULT: LocalCharacterWidgetSettings = {
compact: true,
showOffline: false,
version: 0,
showShipName: false,
};
export function useLocalCharacterWidgetSettings() {
return useLocalStorageState<LocalCharacterWidgetSettings>('kills:widget:settings', {
defaultValue: LOCAL_CHARACTER_WIDGET_DEFAULT,
});
}

View File

@@ -0,0 +1,114 @@
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';
import { isWormholeSpace } from '@/hooks/Mapper/components/map/helpers/isWormholeSpace';
export const SystemKills: React.FC = () => {
const {
data: { selectedSystems, systems, isSubscriptionActive },
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;
const filteredKills = useMemo(() => {
if (!settings.whOnly || !visible) return kills;
return kills.filter(kill => {
const system = systems.find(sys => sys.system_static_info.solar_system_id === kill.solar_system_id);
if (!system) {
console.warn(`System with id ${kill.solar_system_id} not found.`);
return false;
}
return isWormholeSpace(system.system_static_info.system_class);
});
}, [kills, settings.whOnly, systems]);
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)} />
}
>
{!isSubscriptionActive && (
<div className="w-full h-full flex justify-center items-center select-none text-center text-stone-400/80 text-sm">
Kills available with &#39;Active&#39; map subscription only (contact map administrators)
</div>
)}
{isSubscriptionActive && (
<>
{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 &&
(!filteredKills || filteredKills.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={filteredKills}
systemNameMap={systemNameMap}
compact={settings.compact}
onlyOneSystem={!visible}
/>
</div>
)}
</>
)}
</Widget>
</div>
<KillsSettingsDialog visible={settingsDialogVisible} setVisible={setSettingsDialogVisible} />
</div>
);
};

View File

@@ -0,0 +1,16 @@
.TableRowCompact {
height: 8px;
max-height: 8px;
font-size: 12px !important;
line-height: 8px;
}
.Table {
font-size: 12px;
border-collapse: collapse;
}
.Tooltip {
white-space: pre-line;
line-height: 1.2rem;
}

View File

@@ -0,0 +1,50 @@
import React, { useMemo } from 'react';
import clsx from 'clsx';
import { DetailedKill } from '@/hooks/Mapper/types/kills';
import { KillRow } from '../components/SystemKillsRow';
interface SystemKillsContentProps {
kills: DetailedKill[];
systemNameMap: Record<string, string>;
compact?: boolean;
onlyOneSystem?: boolean;
}
export const SystemKillsContent: React.FC<SystemKillsContentProps> = ({
kills,
systemNameMap,
compact = false,
onlyOneSystem = false,
}) => {
const sortedKills = useMemo(() => {
return [...kills].sort((a, b) => {
const timeA = a.kill_time ? new Date(a.kill_time).getTime() : 0;
const timeB = b.kill_time ? new Date(b.kill_time).getTime() : 0;
return timeB - timeA;
});
}, [kills]);
return (
<div
className={clsx(
'flex flex-col w-full text-stone-200 text-xs transition-all duration-300',
compact ? 'p-1' : 'p-1'
)}
>
{sortedKills.map(kill => {
const systemIdStr = String(kill.solar_system_id);
const systemName = systemNameMap[systemIdStr] || `System ${systemIdStr}`;
return (
<KillRow
key={kill.killmail_id}
killDetails={kill}
systemName={systemName}
isCompact={compact}
onlyOneSystem={onlyOneSystem}
/>
);
})}
</div>
);
};

View File

@@ -0,0 +1,52 @@
import React from 'react';
import clsx from 'clsx';
import { zkillLink } from '../helpers';
import classes from './SystemKillRow.module.scss';
interface AttackerRowSubInfoProps {
finalBlowCharId: number | null | undefined;
finalBlowCharName?: string;
attackerPortraitUrl: string | null;
finalBlowCorpId: number | null | undefined;
finalBlowCorpName?: string;
attackerCorpLogoUrl: string | null;
finalBlowAllianceId: number | null | undefined;
finalBlowAllianceName?: string;
attackerAllianceLogoUrl: string | null;
containerHeight?: number;
}
export const AttackerRowSubInfo: React.FC<AttackerRowSubInfoProps> = ({
finalBlowCharId = 0,
finalBlowCharName,
attackerPortraitUrl,
containerHeight = 8,
}) => {
if (!attackerPortraitUrl || finalBlowCharId === null || finalBlowCharId <= 0) {
return null;
}
const containerClass = `h-${containerHeight}`;
return (
<div className={clsx('flex items-start gap-1', containerClass)}>
<div className="relative shrink-0 w-auto h-full overflow-hidden">
<a
href={zkillLink('character', finalBlowCharId)}
target="_blank"
rel="noopener noreferrer"
className="block h-full"
>
<img
src={attackerPortraitUrl}
alt={finalBlowCharName || 'AttackerPortrait'}
className={clsx(classes.killRowImage, 'h-full w-auto object-contain')}
/>
</a>
</div>
</div>
);
};

View File

@@ -0,0 +1,235 @@
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
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,
// Attacker
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;
// Tickers & strings
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);
// Victim images, including the ship
const {
victimCorpLogoUrl,
victimAllianceLogoUrl,
victimShipUrl,
} = buildVictimImageUrls({
victim_char_id,
victim_ship_type_id,
victim_corp_id,
victim_alliance_id,
});
// Attacker corp/alliance
const { attackerCorpLogoUrl, attackerAllianceLogoUrl } = buildAttackerImageUrls({
final_blow_char_id,
final_blow_corp_id,
final_blow_alliance_id,
});
// Victim corp/alliance logo
const { url: victimPrimaryLogoUrl, tooltip: victimPrimaryTooltip } =
getPrimaryLogoAndTooltip(
victimAllianceLogoUrl,
victimCorpLogoUrl,
victim_alliance_name,
victim_corp_name,
'Victim'
);
// Attacker corp/alliance or NPC ship
const { url: attackerPrimaryImageUrl, tooltip: attackerPrimaryTooltip } =
getAttackerPrimaryImageAndTooltip(
attackerIsNpc,
attackerAllianceLogoUrl,
attackerCorpLogoUrl,
final_blow_alliance_name,
final_blow_corp_name,
final_blow_ship_type_id
);
return (
<div
className={clsx(
'h-10 flex items-center border-b border-stone-800',
'text-xs whitespace-nowrap overflow-hidden leading-none'
)}
>
<div className="flex items-center gap-1">
{victimShipUrl && (
<div className="relative shrink-0 w-8 h-8 overflow-hidden">
<a
href={zkillLink('kill', killmail_id)}
target="_blank"
rel="noopener noreferrer"
className="block w-full h-full"
>
<img
src={victimShipUrl}
alt="VictimShip"
className={clsx(
classes.killRowImage,
'w-full h-full object-contain'
)}
/>
</a>
</div>
)}
{victimPrimaryLogoUrl && (
<WdTooltipWrapper
content={victimPrimaryTooltip}
position={TooltipPosition.top}
>
<a
href={zkillLink('kill', killmail_id)}
target="_blank"
rel="noopener noreferrer"
className="relative block 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>
<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 block 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]'
)}
>
{attackerSubscript.label}
</span>
)}
</a>
</WdTooltipWrapper>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,263 @@
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
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 = '',
// Attacker
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 || null;
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';
// Victim images, now also pulling victimShipUrl
const {
victimPortraitUrl,
victimCorpLogoUrl,
victimAllianceLogoUrl,
victimShipUrl,
} = buildVictimImageUrls({
victim_char_id,
victim_ship_type_id,
victim_corp_id,
victim_alliance_id,
});
// Attacker images
const {
attackerPortraitUrl,
attackerCorpLogoUrl,
attackerAllianceLogoUrl,
} = buildAttackerImageUrls({
final_blow_char_id,
final_blow_corp_id,
final_blow_alliance_id,
});
// Primary corp/alliance logo for victim
const { url: victimPrimaryImageUrl, tooltip: victimPrimaryTooltip } =
getPrimaryLogoAndTooltip(
victimAllianceLogoUrl,
victimCorpLogoUrl,
victim_alliance_name,
victim_corp_name,
'Victim'
);
// Primary image for attacker => NPC => ship, else corp/alliance
const { url: attackerPrimaryImageUrl, tooltip: attackerPrimaryTooltip } =
getAttackerPrimaryImageAndTooltip(
attackerIsNpc,
attackerAllianceLogoUrl,
attackerCorpLogoUrl,
final_blow_alliance_name,
final_blow_corp_name,
final_blow_ship_type_id
);
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">
{victimShipUrl && (
<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={victimShipUrl}
alt="VictimShip"
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
/>
</a>
</div>
)}
{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 && final_blow_char_id > 0 && (
<div className="relative shrink-0 w-14 h-14 overflow-hidden">
<a
href={zkillLink('character', final_blow_char_id)}
target="_blank"
rel="noopener noreferrer"
className="block w-full h-full"
>
<img
src={attackerPortraitUrl}
alt="AttackerPortrait"
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
/>
</a>
</div>
)}
{attackerPrimaryImageUrl && (
<WdTooltipWrapper
content={attackerPrimaryTooltip}
position={TooltipPosition.top}
>
<div className="relative shrink-0 w-14 h-14 overflow-hidden">
<a
href={zkillLink('kill', killmail_id)}
target="_blank"
rel="noopener noreferrer"
className="block w-full h-full"
>
<img
src={attackerPrimaryImageUrl}
alt={attackerIsNpc ? 'NpcShip' : 'AttackerPrimaryLogo'}
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
/>
{attackerSubscript && (
<span
className={clsx(
attackerSubscript.cssClass,
classes.attackerCountLabel
)}
>
{attackerSubscript.label}
</span>
)}
</a>
</div>
</WdTooltipWrapper>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,30 @@
.killRowContainer {
@apply flex items-center whitespace-nowrap overflow-hidden;
&:not(:last-child) {
@apply border-b border-stone-800;
}
@apply bg-transparent transition-all hover:bg-stone-900 hover:border-stone-700;
}
.killRowImage {
@apply border border-stone-800 rounded-[4px] object-contain;
}
.attackerCountLabel {
position: absolute;
bottom: 0;
right: 0;
font-size: 10px;
padding: 0 2px;
}
.attackerCountLabelCompact {
position: absolute;
left: 0;
bottom: 0;
font-size: 0.6rem;
line-height: 1;
background-color: rgba(0, 0, 0, 0.7);
padding: 1px 2px;
pointer-events: none;
}

View File

@@ -0,0 +1,56 @@
import React from 'react';
import {
LayoutEventBlocker,
WdCheckbox,
WdImgButton,
TooltipPosition,
SystemView,
} from '@/hooks/Mapper/components/ui-kit';
import { useKillsWidgetSettings } from '../hooks/useKillsWidgetSettings';
import { PrimeIcons } from 'primereact/api';
interface KillsWidgetHeaderProps {
systemId?: string;
onOpenSettings: () => void;
}
export const KillsHeader: React.FC<KillsWidgetHeaderProps> = ({ systemId, onOpenSettings }) => {
const [settings, setSettings] = useKillsWidgetSettings();
const { showAll } = settings;
const onToggleShowAllVisible = () => {
setSettings(prev => ({ ...prev, showAll: !prev.showAll }));
};
return (
<div className="flex justify-between items-center text-xs w-full">
<div className="flex items-center gap-1">
<div className="text-stone-400">
Kills
{systemId && !showAll && ' in '}
</div>
{systemId && !showAll && <SystemView systemId={systemId} className="select-none text-center" hideRegion />}
</div>
<LayoutEventBlocker className="flex gap-2 items-center">
<WdCheckbox
size="xs"
labelSide="left"
label="Show all systems"
value={showAll}
classNameLabel="text-stone-400 hover:text-stone-200 transition duration-300"
onChange={onToggleShowAllVisible}
/>
<WdImgButton
className={PrimeIcons.SLIDERS_H}
onClick={onOpenSettings}
tooltip={{
content: 'Open Kills Settings',
position: TooltipPosition.left,
}}
/>
</LayoutEventBlocker>
</div>
);
};

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { DetailedKill } from '@/hooks/Mapper/types/kills';
import { CompactKillRow } from './CompactKillRow';
import { FullKillRow } from './FullKillRow';
export interface KillRowProps {
killDetails: DetailedKill;
systemName: string;
isCompact?: boolean;
onlyOneSystem?: boolean;
}
export const KillRow: React.FC<KillRowProps> = ({
killDetails,
systemName,
isCompact = false,
onlyOneSystem = false,
}) => {
if (isCompact) {
return <CompactKillRow killDetails={killDetails} systemName={systemName} onlyOneSystem={onlyOneSystem} />;
}
return <FullKillRow killDetails={killDetails} systemName={systemName} onlyOneSystem={onlyOneSystem} />;
};

View File

@@ -0,0 +1,156 @@
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,
whOnly: globalSettings.whOnly,
excludedSystems: globalSettings.excludedSystems || [],
});
const [, forceRender] = useState(0);
const [addSystemDialogVisible, setAddSystemDialogVisible] = useState(false);
useEffect(() => {
if (visible) {
localRef.current = {
compact: globalSettings.compact,
showAll: globalSettings.showAll,
whOnly: globalSettings.whOnly,
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 handleWHChange = useCallback((checked: boolean) => {
localRef.current = {
...localRef.current,
whOnly: 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 items-center gap-2">
<input
type="checkbox"
id="kills-wormhole-only-mode"
checked={localData.whOnly}
onChange={e => handleWHChange(e.target.checked)}
/>
<label htmlFor="kills-wh-only-mode" className="cursor-pointer">
Only show wormhole kills
</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>
<div className="flex gap-2 justify-end mt-4">
<Button onClick={handleApply} label="Apply" outlined size="small" />
</div>
</div>
<AddSystemDialog
title="Add system to kills exclude list"
visible={addSystemDialogVisible}
setVisible={() => setAddSystemDialogVisible(false)}
onSubmit={handleAddSystemSubmit}
excludedSystems={excluded}
/>
</Dialog>
);
};

View File

@@ -0,0 +1,40 @@
// VictimSubRowInfo.tsx
import React from 'react';
import clsx from 'clsx';
import { zkillLink } from '../helpers';
import classes from './SystemKillRow.module.scss';
interface VictimRowSubInfoProps {
victimCharacterId: number | null;
victimPortraitUrl: string | null;
victimCharName?: string;
}
export const VictimRowSubInfo: React.FC<VictimRowSubInfoProps> = ({
victimCharacterId = 0,
victimPortraitUrl,
victimCharName,
}) => {
if (!victimPortraitUrl || victimCharacterId === null || victimCharacterId <= 0) {
return null;
}
return (
<div className="flex items-start gap-1 h-14">
<div className="relative shrink-0 w-14 h-14 overflow-hidden">
<a
href={zkillLink('character', victimCharacterId)}
target="_blank"
rel="noopener noreferrer"
className="block w-full h-full"
>
<img
src={victimPortraitUrl}
alt={victimCharName || 'Victim Portrait'}
className={clsx(classes.killRowImage, 'w-full h-full object-contain')}
/>
</a>
</div>
</div>
);
};

View File

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

View File

@@ -0,0 +1,47 @@
import { DetailedKill } from '@/hooks/Mapper/types/kills';
/** Returns "5m ago", "3h ago", "2.5d ago", etc. */
export function formatTimeMixed(killTime: string): string {
const killDate = new Date(killTime);
const diffMs = Date.now() - killDate.getTime();
const diffHours = diffMs / (1000 * 60 * 60);
if (diffHours < 1) {
const mins = Math.round(diffHours * 60);
return `${mins}m ago`;
} else if (diffHours < 24) {
const hours = Math.round(diffHours);
return `${hours}h ago`;
} else {
const days = diffHours / 24;
const roundedDays = days.toFixed(1);
return `${roundedDays}d ago`;
}
}
/** Formats integer ISK values into k/M/B/T. */
export function formatISK(value: number): string {
if (value >= 1_000_000_000_000) {
return `${(value / 1_000_000_000_000).toFixed(2)}T`;
} else if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`;
} else if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2)}M`;
} else if (value >= 1_000) {
return `${(value / 1_000).toFixed(2)}k`;
}
return Math.round(value).toString();
}
export function getAttackerSubscript(kill: DetailedKill) {
if (kill.npc) {
return { label: 'npc', cssClass: 'text-purple-400' };
}
const count = kill.attacker_count ?? 0;
if (count === 1) {
return { label: 'solo', cssClass: 'text-green-400' };
} else if (count > 1) {
return { label: String(count), cssClass: 'text-white' };
}
return null;
}

View File

@@ -0,0 +1,111 @@
const ZKILL_URL = 'https://zkillboard.com';
const BASE_IMAGE_URL = 'https://images.evetech.net';
export function zkillLink(type: 'kill' | 'character' | 'corporation' | 'alliance', id?: number | null): string {
if (!id) return `${ZKILL_URL}`;
if (type === 'kill') return `${ZKILL_URL}/kill/${id}/`;
if (type === 'character') return `${ZKILL_URL}/character/${id}/`;
if (type === 'corporation') return `${ZKILL_URL}/corporation/${id}/`;
if (type === 'alliance') return `${ZKILL_URL}/alliance/${id}/`;
return `${ZKILL_URL}`;
}
export function eveImageUrl(
category: 'characters' | 'corporations' | 'alliances' | 'types',
id?: number | null,
variation: string = 'icon',
size?: number,
): string | null {
if (!id || id <= 0) {
return null;
}
let url = `${BASE_IMAGE_URL}/${category}/${id}/${variation}`;
if (size) {
url += `?size=${size}`;
}
return url;
}
export function buildVictimImageUrls(args: {
victim_char_id?: number | null;
victim_ship_type_id?: number | null;
victim_corp_id?: number | null;
victim_alliance_id?: number | null;
}) {
const { victim_char_id, victim_ship_type_id, victim_corp_id, victim_alliance_id } = args;
const victimPortraitUrl = eveImageUrl('characters', victim_char_id, 'portrait', 64);
const victimShipUrl = eveImageUrl('types', victim_ship_type_id, 'render', 64);
const victimCorpLogoUrl = eveImageUrl('corporations', victim_corp_id, 'logo', 32);
const victimAllianceLogoUrl = eveImageUrl('alliances', victim_alliance_id, 'logo', 32);
return {
victimPortraitUrl,
victimShipUrl,
victimCorpLogoUrl,
victimAllianceLogoUrl,
};
}
export function buildAttackerShipUrl(final_blow_ship_type_id?: number | null): string | null {
return eveImageUrl('types', final_blow_ship_type_id, 'render', 64);
}
export function buildAttackerImageUrls(args: {
final_blow_char_id?: number | null;
final_blow_corp_id?: number | null;
final_blow_alliance_id?: number | null;
}) {
const { final_blow_char_id, final_blow_corp_id, final_blow_alliance_id } = args;
const attackerPortraitUrl = eveImageUrl('characters', final_blow_char_id, 'portrait', 64);
const attackerCorpLogoUrl = eveImageUrl('corporations', final_blow_corp_id, 'logo', 32);
const attackerAllianceLogoUrl = eveImageUrl('alliances', final_blow_alliance_id, 'logo', 32);
return {
attackerPortraitUrl,
attackerCorpLogoUrl,
attackerAllianceLogoUrl,
};
}
export function getPrimaryLogoAndTooltip(
allianceUrl: string | null,
corpUrl: string | null,
allianceName: string,
corpName: string,
fallback: string,
) {
let url: string | null = null;
let tooltip = '';
if (allianceUrl) {
url = allianceUrl;
tooltip = allianceName || fallback;
} else if (corpUrl) {
url = corpUrl;
tooltip = corpName || fallback;
}
return { url, tooltip };
}
export function getAttackerPrimaryImageAndTooltip(
isNpc: boolean,
allianceUrl: string | null,
corpUrl: string | null,
allianceName: string,
corpName: string,
finalBlowShipTypeId: number | null,
npcFallback: string = 'NPC Attacker',
) {
if (isNpc) {
const shipUrl = buildAttackerShipUrl(finalBlowShipTypeId);
return {
url: shipUrl,
tooltip: npcFallback,
};
}
return getPrimaryLogoAndTooltip(allianceUrl, corpUrl, allianceName, corpName, 'Attacker');
}

View File

@@ -0,0 +1,53 @@
import { useMemo, useCallback } from 'react';
import useLocalStorageState from 'use-local-storage-state';
export interface KillsWidgetSettings {
compact: boolean;
showAll: boolean;
whOnly: boolean;
excludedSystems: number[];
version: number;
}
export const DEFAULT_KILLS_WIDGET_SETTINGS: KillsWidgetSettings = {
compact: true,
showAll: false,
whOnly: true,
excludedSystems: [],
version: 0,
};
function mergeWithDefaults(settings?: Partial<KillsWidgetSettings>): KillsWidgetSettings {
if (!settings) {
return DEFAULT_KILLS_WIDGET_SETTINGS;
}
return {
...DEFAULT_KILLS_WIDGET_SETTINGS,
...settings,
excludedSystems: Array.isArray(settings.excludedSystems) ? settings.excludedSystems : [],
};
}
export function useKillsWidgetSettings() {
const [rawValue, setRawValue] = useLocalStorageState<KillsWidgetSettings | undefined>('kills:widget:settings');
const value = useMemo<KillsWidgetSettings>(() => {
return mergeWithDefaults(rawValue);
}, [rawValue]);
const setValue = useCallback(
(newVal: KillsWidgetSettings | ((prev: KillsWidgetSettings) => KillsWidgetSettings)) => {
setRawValue(prev => {
const mergedPrev = mergeWithDefaults(prev);
const nextUnmerged = typeof newVal === 'function' ? newVal(mergedPrev) : newVal;
return mergeWithDefaults(nextUnmerged);
});
},
[setRawValue],
);
return [value, setValue] as const;
}

View File

@@ -0,0 +1,183 @@
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;
// When showing all visible kills, filter out excluded systems;
// when showAllVisible is false, ignore the exclusion filter.
const effectiveSystemIds = useMemo(() => {
if (showAllVisible) {
return systems.map(s => s.id).filter(id => !excludedSystems.includes(Number(id)));
}
return systems.map(s => s.id);
}, [systems, excludedSystems, showAllVisible]);
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: effectiveSystemIds,
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 systems => `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, effectiveSystemIds, sinceHours, mergeKillsIntoGlobal],
);
const debouncedFetchKills = useMemo(
() =>
debounce(fetchKills, 500, {
leading: true,
trailing: false,
}),
[fetchKills],
);
const finalKills = useMemo(() => {
if (showAllVisible) {
return effectiveSystemIds.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 effectiveSystemIds.flatMap(sid => detailedKills[sid] ?? []);
}
return [];
}, [showAllVisible, systemId, effectiveSystemIds, 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 showAllVisible is true
}
}, [systemId, showAllVisible, debouncedFetchKills, fetchKills]);
useEffect(() => {
if (effectiveSystemIds.length === 0) return;
if (showAllVisible || systemId) {
debouncedFetchKills();
// Clean up the debounce on unmount or changes
return () => debouncedFetchKills.cancel();
}
}, [showAllVisible, systemId, effectiveSystemIds, debouncedFetchKills]);
const refetch = useCallback(() => {
debouncedFetchKills.cancel();
fetchKills(); // immediate (non-debounced) call
}, [debouncedFetchKills, fetchKills]);
return {
kills: finalKills,
isLoading: effectiveIsLoading,
error,
refetch,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@
border-style: solid;
border-color: #272727;
background-color: rgba(0, 0, 0, 0);
border-radius: 3px;
border-radius: 0 !important;
}
.CharName {
@@ -26,7 +26,10 @@
}
}
.CharIcon {}
.CharIcon {
border-radius: 0 !important;
border: 1px solid #2b2b2b;
}
.CharRow {
display: grid;

View File

@@ -3,13 +3,13 @@ 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 = {
compact?: boolean;
showShipName?: boolean;
showSystem?: boolean;
showShipName?: boolean;
useSystemsCache?: boolean;
} & CharacterTypeRaw &
WithIsOwnCharacter;
@@ -18,16 +18,22 @@ 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))
);
};
const Divider = () => (
<span className="mx-1 text-gray-400" aria-hidden="true">
|
</span>
);
export const CharacterCard = ({
compact,
compact = false,
isOwn,
showSystem,
showShipName,
@@ -41,57 +47,114 @@ export const CharacterCard = ({
});
}, [char]);
return (
<div className={clsx(classes.CharacterCard, 'w-full text-xs', 'flex flex-col box-border')} onClick={handleSelect}>
<div className="flex px-2 py-1 gap-1">
{!compact && (
<span
className={clsx(classes.EveIcon, classes.CharIcon, 'wd-bg-default')}
style={{ backgroundImage: `url(https://images.evetech.net/characters/${char.eve_id}/portrait)` }}
const shipNameText = char.ship?.ship_name ? getShipName(char.ship.ship_name) : '';
const tickerText = char.alliance_id ? char.alliance_ticker : char.corporation_ticker;
const shipType = char.ship?.ship_type_info?.name;
if (compact) {
return (
<div
className={clsx(classes.CharacterCard, 'w-full text-xs box-border')}
onClick={handleSelect}
>
<div className="w-full px-2 py-1 flex items-center gap-2" style={{ minWidth: 0 }}>
<img
src={`https://images.evetech.net/characters/${char.eve_id}/portrait`}
alt={`${char.name} portrait`}
style={{
width: '18px',
height: '18px',
borderRadius: 0,
flexShrink: 0,
border: '1px solid #2b2b2b',
}}
/>
)}
<div className="flex flex-col flex-grow">
<div
className={clsx(classes.CharRow, 'w-full', {
[classes.TwoColumns]: !char.ship,
[classes.ThreeColumns]: char.ship,
})}
>
<span
className={clsx(classes.CharName, 'text-ellipsis overflow-hidden whitespace-nowrap', {
[classes.CardBorderLeftIsOwn]: isOwn,
})}
title={char.name}
>
{char.name}
</span>
{char.alliance_id && <span className="text-gray-400">[{char.alliance_ticker}]</span>}
{!char.alliance_id && <span className="text-gray-400">[{char.corporation_ticker}]</span>}
{char.ship?.ship_type_info && (
<div
className="flex-grow text-ellipsis overflow-hidden whitespace-nowrap"
title={char.ship.ship_type_info.name}
>
{char.ship.ship_type_info.name}
</div>
)}
</div>
{showShipName && !compact && char.ship?.ship_name && (
<div className="grid w-full">
<span className="text-ellipsis overflow-hidden whitespace-nowrap">
{getShipName(char.ship.ship_name)}
</span>
<div className="flex flex-grow overflow-hidden text-left" style={{ minWidth: 0 }}>
<div className="overflow-hidden text-ellipsis whitespace-nowrap">
<span className="text-gray-200">{char.name}</span>
<Divider />
{showShipName && shipNameText ? (
<span className="text-indigo-300">{shipNameText}</span>
) : (
<span className="text-indigo-300">[{tickerText}]</span>
)}
</div>
</div>
{shipType && (
<div
className="text-yellow-400 overflow-hidden text-ellipsis whitespace-nowrap flex-shrink-0"
style={{ maxWidth: '120px' }}
title={shipType}
>
{shipType}
</div>
)}
{showSystem && !compact && char.location?.solar_system_id && (
<SystemView systemId={char.location.solar_system_id.toString()} useSystemsCache={useSystemsCache} />
)}
</div>
</div>
</div>
);
);
} else {
const locationShown = showSystem && char.location?.solar_system_id;
return (
<div
className={clsx(classes.CharacterCard, 'w-full text-xs box-border')}
onClick={handleSelect}
>
<div className="w-full px-2 py-1 flex items-center gap-2" style={{ minWidth: 0 }}>
<span
className={clsx(classes.EveIcon, classes.CharIcon, 'wd-bg-default')}
style={{
backgroundImage: `url(https://images.evetech.net/characters/${char.eve_id}/portrait)`,
minWidth: '33px',
minHeight: '33px',
width: '33px',
height: '33px',
}}
/>
<div className="flex flex-col flex-grow overflow-hidden" style={{ minWidth: 0 }}>
<div className="overflow-hidden text-ellipsis whitespace-nowrap">
<span className="text-gray-200">{char.name}</span>
<Divider />
<span className="text-indigo-300">[{tickerText}]</span>
</div>
{locationShown ? (
<div className="text-gray-300 text-xs overflow-hidden text-ellipsis whitespace-nowrap">
<SystemView
systemId={char?.location?.solar_system_id?.toString() || '' }
useSystemsCache={useSystemsCache}
/>
</div>
) : (
shipNameText && (
<div className="text-gray-300 text-xs overflow-hidden text-ellipsis whitespace-nowrap">
{shipNameText}
</div>
)
)}
</div>
{((shipType) || (locationShown && shipNameText)) && (
<div className="flex-shrink-0 self-start">
{shipType && (
<div
className="text-yellow-400 overflow-hidden text-ellipsis whitespace-nowrap"
style={{ maxWidth: '200px' }}
title={shipType}
>
{shipType}
</div>
)}
{locationShown && shipNameText && (
<div
className="text-gray-300 text-xs overflow-hidden text-ellipsis whitespace-nowrap text-right"
style={{ maxWidth: '200px' }}
>
{shipNameText}
</div>
)}
</div>
)}
</div>
</div>
);
}
};

View File

@@ -1,20 +1,17 @@
import React, {
ForwardedRef,
forwardRef,
MouseEvent,
MouseEventHandler,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
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',
@@ -24,16 +21,12 @@ export enum TooltipPosition {
bottom = 'bottom',
}
export interface TooltipProps {
export interface TooltipProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'content'> {
position?: TooltipPosition;
offset?: number;
content: (() => React.ReactNode) | React.ReactNode;
targetSelector?: string;
}
export interface WdTooltipHandlers {
show: MouseEventHandler;
hide: MouseEventHandler;
interactive?: boolean;
}
export interface OffsetPosition {
@@ -41,169 +34,263 @@ 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: () => void;
getIsMouseInside: () => boolean;
}
interface TriggerInfo {
clientX: number;
clientY: number;
rect: DOMRect;
}
const LEAVE_DELAY = 100;
export const WdTooltip = forwardRef(function WdTooltip(
{
content,
targetSelector,
position: tPosition = TooltipPosition.default,
offset = 5,
interactive = false,
className,
...restProps
}: TooltipProps,
ref: ForwardedRef<WdTooltipHandlers>
) {
// Always initialize position so we never have a null value.
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState<OffsetPosition | null>(null);
const [ev, setEv] = useState<MouseEvent>();
const [pos, setPos] = useState<OffsetPosition>({ left: 0, top: 0 });
const tooltipRef = useRef<HTMLDivElement>(null);
const calcTooltipPosition = useCallback(({ x, y }: { x: number; y: number }) => {
let newLeft = x;
let newTop = y;
const [isMouseInsideTooltip, setIsMouseInsideTooltip] = useState(false);
if (!tooltipRef.current) {
return { left: newLeft, top: newTop };
}
const [triggerInfo, setTriggerInfo] = useState<TriggerInfo | null>(null);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
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;
if (newLeft < 0) {
newLeft = 10;
}
let newLeft = x;
let newTop = y;
if (newTop < 0) {
newTop = 10;
}
if (newLeft < 0) newLeft = 10;
if (newTop < 0) newTop = 10;
if (newLeft + tooltipWidth + 10 > window.innerWidth) {
const rightEdge = newLeft + tooltipWidth + 10;
if (rightEdge > window.innerWidth) {
newLeft = window.innerWidth - tooltipWidth - 10;
}
if (newTop + tooltipHeight + 10 > window.innerHeight) {
const bottomEdge = newTop + tooltipHeight + 10;
if (bottomEdge > window.innerHeight) {
newTop = window.innerHeight - tooltipHeight - 10;
}
return { left: newLeft, top: newTop };
}, []);
useEffect(() => {
if (!tooltipRef.current || !ev) {
const scheduleHide = useCallback(() => {
if (!interactive) {
setVisible(false);
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) {
offsetX = targetBounds.left + targetBounds.width + offset;
}
setPosition(calcTooltipPosition({ x: offsetX, y: offsetY }));
return;
if (!hideTimeoutRef.current) {
hideTimeoutRef.current = setTimeout(() => {
setVisible(false);
}, LEAVE_DELAY);
}
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]);
}, [interactive]);
useImperativeHandle(ref, () => ({
show: e => {
setEv(e);
show: (e?: React.MouseEvent) => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
if (e) {
// Use e.currentTarget (or fallback to e.target) to determine the trigger element.
const triggerEl = (e.currentTarget as HTMLElement) || (e.target as HTMLElement);
if (triggerEl) {
const rect = triggerEl.getBoundingClientRect();
setTriggerInfo({ clientX: e.clientX, clientY: e.clientY, rect });
setPos(calcTooltipPosition({ x: e.clientX, y: e.clientY }));
}
}
setVisible(true);
setPosition(null);
},
hide: () => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
setVisible(false);
},
getIsMouseInside: () => isMouseInsideTooltip,
}));
useEffect(() => {
if (targetSelector == null) {
// Recalculate position once the tooltip element has been rendered.
useLayoutEffect(() => {
if (!tooltipRef.current || !triggerInfo) return;
const tooltipEl = tooltipRef.current;
const { rect } = triggerInfo;
let x = triggerInfo.clientX;
let y = triggerInfo.clientY;
if (tPosition === TooltipPosition.left) {
const tooltipBounds = tooltipEl.getBoundingClientRect();
x = rect.left - tooltipBounds.width - offset;
y = rect.top + rect.height / 2 - tooltipBounds.height / 2;
if (x <= 0) {
x = rect.left + rect.width + offset;
}
setPos(calcTooltipPosition({ x, y }));
return;
}
if (tPosition === TooltipPosition.right) {
x = rect.left + rect.width + offset;
y = rect.top + rect.height / 2 - tooltipEl.offsetHeight / 2;
setPos(calcTooltipPosition({ x, y }));
return;
}
if (tPosition === TooltipPosition.top) {
x = rect.left + rect.width / 2 - tooltipEl.offsetWidth / 2;
y = rect.top - tooltipEl.offsetHeight - offset;
setPos(calcTooltipPosition({ x, y }));
return;
}
if (tPosition === TooltipPosition.bottom) {
x = rect.left + rect.width / 2 - tooltipEl.offsetWidth / 2;
y = rect.bottom + offset;
setPos(calcTooltipPosition({ x, y }));
return;
}
// Default case: use stored coordinates.
setPos(calcTooltipPosition({ x, y }));
}, [calcTooltipPosition, triggerInfo, tPosition, offset]);
const handleMouseMove = (e: MouseEvent) => {
const targetElement = e.target as HTMLElement;
useEffect(() => {
if (!targetSelector) return;
if (!targetElement) {
setVisible(false);
const handleMouseMove = (evt: MouseEvent) => {
const targetEl = evt.target as HTMLElement | null;
if (!targetEl) {
scheduleHide();
return;
}
const triggerEl = targetEl.closest(targetSelector);
const insideTooltip = interactive && tooltipRef.current?.contains(targetEl);
if (!triggerEl && !insideTooltip) {
scheduleHide();
return;
}
const nodesFound = [...(targetElement?.parentElement?.querySelectorAll(targetSelector) ?? [])];
if (!nodesFound.includes(targetElement)) {
setVisible(false);
return;
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
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 (triggerEl && tooltipRef.current) {
const rect = triggerEl.getBoundingClientRect();
const tooltipEl = tooltipRef.current;
let x = evt.clientX;
let y = evt.clientY;
switch (tPosition) {
case TooltipPosition.left: {
x = rect.left - tooltipEl.offsetWidth - offset;
y = rect.top + rect.height / 2 - tooltipEl.offsetHeight / 2;
if (x <= 0) {
x = rect.left + rect.width + offset;
}
break;
}
case TooltipPosition.right: {
x = rect.left + rect.width + offset;
y = rect.top + rect.height / 2 - tooltipEl.offsetHeight / 2;
break;
}
case TooltipPosition.top: {
x = rect.left + rect.width / 2 - tooltipEl.offsetWidth / 2;
y = rect.top - tooltipEl.offsetHeight - offset;
break;
}
case TooltipPosition.bottom: {
x = rect.left + rect.width / 2 - tooltipEl.offsetWidth / 2;
y = rect.bottom + offset;
break;
}
default:
}
if (newTop + tooltipHeight + 10 > window.innerHeight) {
newTop = window.innerHeight - tooltipHeight - 10;
}
setPosition({ top: newTop, left: newLeft });
setPos(calcTooltipPosition({ x, y }));
}
};
const deb = debounce(handleMouseMove, 10);
// 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);
const debounced = debounce(handleMouseMove, 15);
const listener = (evt: Event) => {
debounced(evt as MouseEvent);
};
}, [targetSelector]);
document.addEventListener('mousemove', listener);
return () => {
document.removeEventListener('mousemove', listener);
debounced.cancel();
};
}, [targetSelector, interactive, tPosition, offset, calcTooltipPosition, scheduleHide]);
useEffect(() => {
return () => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
};
}, []);
if (!visible) return null;
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,
<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',
className
)}
style={{
top: pos.top,
left: pos.left,
zIndex: 10000,
}}
onMouseEnter={() => {
if (interactive && hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
setIsMouseInsideTooltip(true);
}}
onMouseLeave={() => {
setIsMouseInsideTooltip(false);
if (interactive) {
scheduleHide();
}
}}
{...restProps}
>
{typeof content === 'function' ? content() : content}
</div>,
document.body
);
});
WdTooltip.displayName = 'WdTooltip';

View File

@@ -1,3 +1,25 @@
/* WdTooltipWrapper.module.scss */
.WdTooltipWrapperRoot {
display: inline-block;
}
.wdTooltipSizeXs {
font-size: 0.7rem;
max-width: 150px;
}
.wdTooltipSizeSm {
font-size: 0.8rem;
max-width: 200px;
}
.wdTooltipSizeMd {
font-size: 0.9rem;
max-width: 250px;
}
.wdTooltipSizeLg {
font-size: 1rem !important;
min-width: 350px;
}

View File

@@ -1,49 +1,57 @@
import React, { HTMLProps, MouseEventHandler, useCallback, useRef } from 'react';
import classes from './WdTooltipWrapper.module.scss';
import { WithChildren, WithClassName } from '@/hooks/Mapper/types/common.ts';
import { TooltipProps, WdTooltip, WdTooltipHandlers } from '@/hooks/Mapper/components/ui-kit';
import { forwardRef, HTMLProps, ReactNode } from 'react';
import clsx from 'clsx';
import { WdTooltip, WdTooltipHandlers, TooltipProps } from '@/hooks/Mapper/components/ui-kit';
import classes from './WdTooltipWrapper.module.scss';
type TooltipSize = 'xs' | 'sm' | 'md' | 'lg';
export type WdTooltipWrapperProps = {
content?: (() => React.ReactNode) | React.ReactNode;
} & WithChildren &
WithClassName &
HTMLProps<HTMLDivElement> &
content?: (() => ReactNode) | ReactNode;
size?: TooltipSize;
interactive?: boolean;
} & Omit<HTMLProps<HTMLDivElement>, 'content' | 'size'> &
Omit<TooltipProps, 'content'>;
export const WdTooltipWrapper = ({
className,
children,
content,
offset,
position,
targetSelector,
...props
}: WdTooltipWrapperProps) => {
const tooltipRef = useRef<WdTooltipHandlers>(null);
const handleShowDeleteTooltip: MouseEventHandler = useCallback(e => tooltipRef.current?.show(e), []);
const handleHideDeleteTooltip: MouseEventHandler = useCallback(e => tooltipRef.current?.hide(e), []);
export const WdTooltipWrapper = forwardRef<WdTooltipHandlers, WdTooltipWrapperProps>(
(
{ className, children, content, offset, position, targetSelector, interactive = false, size, ...props },
forwardedRef,
) => {
const suffix = Math.random().toString(36).slice(2, 7);
const autoClass = `wdTooltipAutoTrigger-${suffix}`;
const finalTargetSelector = targetSelector || `.${autoClass}`;
return (
<>
<div
className={clsx(classes.WdTooltipWrapperRoot, className)}
{...props}
{...(content && {
onMouseEnter: handleShowDeleteTooltip,
onMouseLeave: handleHideDeleteTooltip,
})}
>
{children}
return (
<div className={clsx(classes.WdTooltipWrapperRoot, className)} {...props}>
{targetSelector ? <>{children}</> : <div className={autoClass}>{children}</div>}
<WdTooltip
ref={forwardedRef}
offset={offset}
position={position}
content={content}
interactive={interactive}
targetSelector={finalTargetSelector}
className={size ? sizeClass(size) : undefined}
/>
</div>
<WdTooltip
ref={tooltipRef}
offset={offset}
position={position}
content={content}
targetSelector={targetSelector}
/>
</>
);
};
);
},
);
WdTooltipWrapper.displayName = 'WdTooltipWrapper';
function sizeClass(size: TooltipSize) {
switch (size) {
case 'xs':
return classes.wdTooltipSizeXs;
case 'sm':
return classes.wdTooltipSizeSm;
case 'md':
return classes.wdTooltipSizeMd;
case 'lg':
return classes.wdTooltipSizeLg;
default:
return undefined;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,17 +10,18 @@ import { useLoadSystemStatic } from '@/hooks/Mapper/mapRootProvider/hooks/useLoa
import { OutCommand } from '@/hooks/Mapper/types/mapHandlers.ts';
import { emitMapEvent } from '@/hooks/Mapper/events';
import { Commands } from '@/hooks/Mapper/types/mapHandlers.ts';
import { DetailedKill } from '@/hooks/Mapper/types/kills';
export const useCommandsSystems = () => {
const {
update,
data: { systems, systemSignatures },
data: { systems, systemSignatures, detailedKills },
outCommand,
} = useMapRootState();
const { addSystemStatic } = useLoadSystemStatic({ systems: [] });
const ref = useRef({ systems, systemSignatures, update, addSystemStatic });
ref.current = { systems, systemSignatures, update, addSystemStatic };
const ref = useRef({ systems, systemSignatures, update, addSystemStatic, detailedKills });
ref.current = { systems, systemSignatures, update, addSystemStatic, detailedKills };
const addSystems = useCallback((systemsToAdd: CommandAddSystems) => {
const { update, addSystemStatic, systems } = ref.current;
@@ -84,5 +85,23 @@ export const useCommandsSystems = () => {
update({ linkSignatureToSystem: command }, true);
}, []);
return { addSystems, removeSystems, updateSystems, updateSystemSignatures, updateLinkSignatureToSystem };
const updateDetailedKills = useCallback((newKillsMap: Record<string, DetailedKill[]>) => {
const { update, detailedKills } = ref.current;
const updated = { ...detailedKills };
for (const [systemId, killsArr] of Object.entries(newKillsMap)) {
updated[systemId] = killsArr;
}
update({ detailedKills: updated }, true);
}, []);
return {
addSystems,
removeSystems,
updateSystems,
updateSystemSignatures,
updateLinkSignatureToSystem,
updateDetailedKills,
};
};

View File

@@ -21,6 +21,7 @@ export const useMapInit = () => {
hubs,
user_permissions,
options,
is_subscription_active,
}: CommandInit) => {
const updateData: Partial<MapRootData> = {};
@@ -65,6 +66,8 @@ export const useMapInit = () => {
updateData.options = options;
}
updateData.isSubscriptionActive = is_subscription_active;
if (system_static_infos) {
system_static_infos.forEach(static_info => {
addSystemStatic(static_info);

View File

@@ -7,6 +7,7 @@ import {
CommandCharactersUpdated,
CommandCharacterUpdated,
CommandInit,
CommandLinkSignatureToSystem,
CommandMapUpdated,
CommandPresentCharacters,
CommandRemoveConnections,
@@ -29,11 +30,18 @@ import {
} from './api';
import { emitMapEvent } from '@/hooks/Mapper/events';
import { DetailedKill } from '../../types/kills';
export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
const mapInit = useMapInit();
const { addSystems, removeSystems, updateSystems, updateSystemSignatures, updateLinkSignatureToSystem } =
useCommandsSystems();
const {
addSystems,
removeSystems,
updateSystems,
updateSystemSignatures,
updateLinkSignatureToSystem,
updateDetailedKills,
} = useCommandsSystems();
const { addConnections, removeConnections, updateConnection } = useCommandsConnections();
const { charactersUpdated, characterAdded, characterRemoved, characterUpdated, presentCharacters } =
useCommandsCharacters();
@@ -111,6 +119,10 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
// do nothing here
break;
case Commands.detailedKillsUpdated:
updateDetailedKills(data as Record<string, DetailedKill[]>);
break;
default:
console.warn(`JOipP Interface handlers: Unknown command: ${type}`, data);
break;

View File

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

View File

@@ -2,3 +2,38 @@ export type Kill = {
solar_system_id: number;
kills: number;
};
export interface DetailedKill {
killmail_id: number;
solar_system_id: number;
kill_time?: string;
zkb?: Record<string, unknown>;
victim_char_id?: number | null;
victim_char_name?: string;
victim_corp_id?: number | null;
victim_corp_ticker?: string;
victim_corp_name?: string;
victim_alliance_id?: number | null;
victim_alliance_ticker?: string;
victim_alliance_name?: string;
victim_ship_type_id?: number | null;
victim_ship_name?: string;
final_blow_char_id?: number | null;
final_blow_char_name?: string;
final_blow_corp_id?: number | null;
final_blow_corp_ticker?: string;
final_blow_corp_name?: string;
final_blow_alliance_id?: number | null;
final_blow_alliance_ticker?: string;
final_blow_alliance_name?: string;
final_blow_ship_type_id?: number | null;
final_blow_ship_name?: string;
attacker_count?: number | null;
total_value?: number | null;
npc?: boolean;
}

View File

@@ -3,8 +3,8 @@ import { SolarSystemConnection } from '@/hooks/Mapper/types/connection.ts';
import { WormholeDataRaw } from '@/hooks/Mapper/types/wormholes.ts';
import { CharacterTypeRaw } from '@/hooks/Mapper/types/character.ts';
import { RoutesList } from '@/hooks/Mapper/types/routes.ts';
import { Kill } from '@/hooks/Mapper/types/kills.ts';
import { UserPermissions } from '@/hooks/Mapper/types';
import { DetailedKill, Kill } from '@/hooks/Mapper/types/kills.ts';
import { SignatureGroup, UserPermissions } from '@/hooks/Mapper/types';
export enum Commands {
init = 'init',
@@ -21,6 +21,7 @@ export enum Commands {
updateConnection = 'update_connection',
mapUpdated = 'map_updated',
killsUpdated = 'kills_updated',
detailedKillsUpdated = 'detailed_kills_updated',
routes = 'routes',
centerSystem = 'center_system',
selectSystem = 'select_system',
@@ -43,6 +44,7 @@ export type Command =
| Commands.updateConnection
| Commands.mapUpdated
| Commands.killsUpdated
| Commands.detailedKillsUpdated
| Commands.routes
| Commands.selectSystem
| Commands.centerSystem
@@ -76,9 +78,11 @@ export type CommandCharacterRemoved = CharacterTypeRaw;
export type CommandCharacterUpdated = CharacterTypeRaw;
export type CommandPresentCharacters = string[];
export type CommandUpdateConnection = SolarSystemConnection;
export type CommandSignaturesUpdated = string;
export type CommandMapUpdated = Partial<CommandInit>;
export type CommandRoutes = RoutesList;
export type CommandKillsUpdated = Kill[];
export type CommandDetailedKillsUpdated = Record<string, DetailedKill[]>;
export type CommandSelectSystem = string | undefined;
export type CommandCenterSystem = string | undefined;
export type CommandLinkSignatureToSystem = {
@@ -103,6 +107,7 @@ export interface CommandData {
[Commands.mapUpdated]: CommandMapUpdated;
[Commands.routes]: CommandRoutes;
[Commands.killsUpdated]: CommandKillsUpdated;
[Commands.detailedKillsUpdated]: CommandDetailedKillsUpdated;
[Commands.selectSystem]: CommandSelectSystem;
[Commands.centerSystem]: CommandCenterSystem;
[Commands.linkSignatureToSystem]: CommandLinkSignatureToSystem;
@@ -151,6 +156,8 @@ export enum OutCommand {
linkSignatureToSystem = 'link_signature_to_system',
getCorporationNames = 'get_corporation_names',
getCorporationTicker = 'get_corporation_ticker',
getSystemKills = 'get_system_kills',
getSystemsKills = 'get_systems_kills',
// Only UI commands
openSettings = 'open_settings',
@@ -161,4 +168,5 @@ export enum OutCommand {
searchSystems = 'search_systems',
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type OutCommandHandler = <T = any>(event: { type: OutCommand; data: any }) => Promise<T>;

View File

@@ -22,4 +22,5 @@ export type MapUnionTypes = {
connections: SolarSystemConnection[];
userPermissions: Partial<UserPermissions>;
options: Record<string, string | boolean>;
isSubscriptionActive: boolean;
};

View File

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

View File

@@ -1,6 +1,7 @@
import { XYPosition } from 'reactflow';
import { SystemSignature } from '@/hooks/Mapper/types/signatures';
import { DetailedKill } from './kills';
export enum SolarSystemStaticInfoRawNames {
regionId = 'region_id',

View File

@@ -25,10 +25,11 @@
"primeflex": "^3.3.1",
"primeicons": "^7.0.0",
"primereact": "^10.6.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
"react-event-hook": "^3.1.2",
"react-flow-renderer": "^10.3.17",
"react-grid-layout": "^1.3.4",
"react-hook-form": "^7.53.1",
"react-usestateref": "^1.0.9",
"reactflow": "^11.11.4",
@@ -42,12 +43,11 @@
"@tailwindcss/typography": "^0.5.13",
"@types/lodash.debounce": "^4.0.9",
"@types/lodash.isequal": "^4.5.8",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.1",
"@types/react-grid-layout": "^1.3.4",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.3.0",
"@vitejs/plugin-react": "^4.3.3",
"@vitejs/plugin-react-refresh": "^1.3.6",
"autoprefixer": "^10.4.19",
"child_process": "^1.0.2",
@@ -82,5 +82,6 @@
},
"keywords": [],
"author": "",
"license": "ISC"
"license": "ISC",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -1,24 +1,10 @@
import cdn from 'vite-plugin-cdn-import';
import path from 'path';
import react from '@vitejs/plugin-react';
export default {
publicDir: './static',
plugins: [
cdn({
modules: [
{
name: 'react',
var: 'React',
path: `umd/react.production.min.js`,
},
{
name: 'react-dom',
var: 'ReactDOM',
path: `umd/react-dom.production.min.js`,
},
],
}),
],
plugins: [react()],
build: {
target: 'es2018',
format: 'esm',
@@ -27,13 +13,8 @@ export default {
emptyOutDir: true,
assetsInlineLimit: 0,
rollupOptions: {
external: ['react', 'react-dom'],
input: ['app.tsx'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
entryFileNames: 'assets/[name].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name][extname]',

View File

@@ -28,12 +28,26 @@
"@babel/highlight" "^7.24.2"
picocolors "^1.0.0"
"@babel/code-frame@^7.25.9", "@babel/code-frame@^7.26.2":
version "7.26.2"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.26.2.tgz#4b5fab97d33338eff916235055f0ebc21e573a85"
integrity sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==
dependencies:
"@babel/helper-validator-identifier" "^7.25.9"
js-tokens "^4.0.0"
picocolors "^1.0.0"
"@babel/compat-data@^7.23.5":
version "7.24.4"
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz"
integrity sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==
"@babel/core@^7.14.8", "@babel/core@^7.24.5":
"@babel/compat-data@^7.26.5":
version "7.26.5"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.26.5.tgz#df93ac37f4417854130e21d72c66ff3d4b897fc7"
integrity sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg==
"@babel/core@^7.14.8":
version "7.24.5"
resolved "https://registry.npmjs.org/@babel/core/-/core-7.24.5.tgz"
integrity sha512-tVQRucExLQ02Boi4vdPp49svNGcfL2GhdTCT9aldhXgCJVAI21EtRfBettiuLUwce/7r6bFdgs6JFkcdTiFttA==
@@ -54,6 +68,27 @@
json5 "^2.2.3"
semver "^6.3.1"
"@babel/core@^7.26.0":
version "7.26.7"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.26.7.tgz#0439347a183b97534d52811144d763a17f9d2b24"
integrity sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA==
dependencies:
"@ampproject/remapping" "^2.2.0"
"@babel/code-frame" "^7.26.2"
"@babel/generator" "^7.26.5"
"@babel/helper-compilation-targets" "^7.26.5"
"@babel/helper-module-transforms" "^7.26.0"
"@babel/helpers" "^7.26.7"
"@babel/parser" "^7.26.7"
"@babel/template" "^7.25.9"
"@babel/traverse" "^7.26.7"
"@babel/types" "^7.26.7"
convert-source-map "^2.0.0"
debug "^4.1.0"
gensync "^1.0.0-beta.2"
json5 "^2.2.3"
semver "^6.3.1"
"@babel/generator@^7.24.5":
version "7.24.5"
resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.24.5.tgz"
@@ -64,6 +99,17 @@
"@jridgewell/trace-mapping" "^0.3.25"
jsesc "^2.5.1"
"@babel/generator@^7.26.5":
version "7.26.5"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.26.5.tgz#e44d4ab3176bbcaf78a5725da5f1dc28802a9458"
integrity sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw==
dependencies:
"@babel/parser" "^7.26.5"
"@babel/types" "^7.26.5"
"@jridgewell/gen-mapping" "^0.3.5"
"@jridgewell/trace-mapping" "^0.3.25"
jsesc "^3.0.2"
"@babel/helper-compilation-targets@^7.23.6":
version "7.23.6"
resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz"
@@ -75,6 +121,17 @@
lru-cache "^5.1.1"
semver "^6.3.1"
"@babel/helper-compilation-targets@^7.26.5":
version "7.26.5"
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz#75d92bb8d8d51301c0d49e52a65c9a7fe94514d8"
integrity sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==
dependencies:
"@babel/compat-data" "^7.26.5"
"@babel/helper-validator-option" "^7.25.9"
browserslist "^4.24.0"
lru-cache "^5.1.1"
semver "^6.3.1"
"@babel/helper-environment-visitor@^7.22.20":
version "7.22.20"
resolved "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz"
@@ -102,6 +159,14 @@
dependencies:
"@babel/types" "^7.24.0"
"@babel/helper-module-imports@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz#e7f8d20602ebdbf9ebbea0a0751fb0f2a4141715"
integrity sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==
dependencies:
"@babel/traverse" "^7.25.9"
"@babel/types" "^7.25.9"
"@babel/helper-module-transforms@^7.24.5":
version "7.24.5"
resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.5.tgz"
@@ -113,11 +178,25 @@
"@babel/helper-split-export-declaration" "^7.24.5"
"@babel/helper-validator-identifier" "^7.24.5"
"@babel/helper-module-transforms@^7.26.0":
version "7.26.0"
resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz#8ce54ec9d592695e58d84cd884b7b5c6a2fdeeae"
integrity sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==
dependencies:
"@babel/helper-module-imports" "^7.25.9"
"@babel/helper-validator-identifier" "^7.25.9"
"@babel/traverse" "^7.25.9"
"@babel/helper-plugin-utils@^7.24.0", "@babel/helper-plugin-utils@^7.24.5":
version "7.24.5"
resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.5.tgz"
integrity sha512-xjNLDopRzW2o6ba0gKbkZq5YWEBaK3PCyTOY1K2P/O07LGMhMqlMXPxwN4S5/RhWuCobT8z0jrlKGlYmeR1OhQ==
"@babel/helper-plugin-utils@^7.25.9":
version "7.26.5"
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz#18580d00c9934117ad719392c4f6585c9333cc35"
integrity sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==
"@babel/helper-simple-access@^7.24.5":
version "7.24.5"
resolved "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.5.tgz"
@@ -137,16 +216,31 @@
resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz"
integrity sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==
"@babel/helper-string-parser@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c"
integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==
"@babel/helper-validator-identifier@^7.24.5":
version "7.24.5"
resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz"
integrity sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==
"@babel/helper-validator-identifier@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7"
integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==
"@babel/helper-validator-option@^7.23.5":
version "7.23.5"
resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz"
integrity sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==
"@babel/helper-validator-option@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz#86e45bd8a49ab7e03f276577f96179653d41da72"
integrity sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==
"@babel/helpers@^7.24.5":
version "7.24.5"
resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.5.tgz"
@@ -156,6 +250,14 @@
"@babel/traverse" "^7.24.5"
"@babel/types" "^7.24.5"
"@babel/helpers@^7.26.7":
version "7.26.7"
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.26.7.tgz#fd1d2a7c431b6e39290277aacfd8367857c576a4"
integrity sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==
dependencies:
"@babel/template" "^7.25.9"
"@babel/types" "^7.26.7"
"@babel/highlight@^7.24.2":
version "7.24.5"
resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.5.tgz"
@@ -171,20 +273,41 @@
resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.24.5.tgz"
integrity sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==
"@babel/plugin-transform-react-jsx-self@^7.14.5", "@babel/plugin-transform-react-jsx-self@^7.24.5":
"@babel/parser@^7.25.9", "@babel/parser@^7.26.5", "@babel/parser@^7.26.7":
version "7.26.7"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.7.tgz#e114cd099e5f7d17b05368678da0fb9f69b3385c"
integrity sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==
dependencies:
"@babel/types" "^7.26.7"
"@babel/plugin-transform-react-jsx-self@^7.14.5":
version "7.24.5"
resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.24.5.tgz"
integrity sha512-RtCJoUO2oYrYwFPtR1/jkoBEcFuI1ae9a9IMxeyAVa3a1Ap4AnxmyIKG2b2FaJKqkidw/0cxRbWN+HOs6ZWd1w==
dependencies:
"@babel/helper-plugin-utils" "^7.24.5"
"@babel/plugin-transform-react-jsx-source@^7.14.5", "@babel/plugin-transform-react-jsx-source@^7.24.1":
"@babel/plugin-transform-react-jsx-self@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz#c0b6cae9c1b73967f7f9eb2fca9536ba2fad2858"
integrity sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==
dependencies:
"@babel/helper-plugin-utils" "^7.25.9"
"@babel/plugin-transform-react-jsx-source@^7.14.5":
version "7.24.1"
resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.24.1.tgz"
integrity sha512-1v202n7aUq4uXAieRTKcwPzNyphlCuqHHDcdSNc+vdhoTEZcFMh+L5yZuCmGaIO7bs1nJUNfHB89TZyoL48xNA==
dependencies:
"@babel/helper-plugin-utils" "^7.24.0"
"@babel/plugin-transform-react-jsx-source@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz#4c6b8daa520b5f155b5fb55547d7c9fa91417503"
integrity sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==
dependencies:
"@babel/helper-plugin-utils" "^7.25.9"
"@babel/runtime@^7.12.5":
version "7.25.0"
resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.0.tgz"
@@ -215,6 +338,15 @@
"@babel/parser" "^7.24.0"
"@babel/types" "^7.24.0"
"@babel/template@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.25.9.tgz#ecb62d81a8a6f5dc5fe8abfc3901fc52ddf15016"
integrity sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==
dependencies:
"@babel/code-frame" "^7.25.9"
"@babel/parser" "^7.25.9"
"@babel/types" "^7.25.9"
"@babel/traverse@^7.24.5":
version "7.24.5"
resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.5.tgz"
@@ -231,6 +363,19 @@
debug "^4.3.1"
globals "^11.1.0"
"@babel/traverse@^7.25.9", "@babel/traverse@^7.26.7":
version "7.26.7"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.26.7.tgz#99a0a136f6a75e7fb8b0a1ace421e0b25994b8bb"
integrity sha512-1x1sgeyRLC3r5fQOM0/xtQKsYjyxmFjaOrLJNtZ81inNjyJHGIolTULPiSc/2qe1/qfpFLisLQYFnnZl7QoedA==
dependencies:
"@babel/code-frame" "^7.26.2"
"@babel/generator" "^7.26.5"
"@babel/parser" "^7.26.7"
"@babel/template" "^7.25.9"
"@babel/types" "^7.26.7"
debug "^4.3.1"
globals "^11.1.0"
"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.24.0", "@babel/types@^7.24.5":
version "7.24.5"
resolved "https://registry.npmjs.org/@babel/types/-/types-7.24.5.tgz"
@@ -240,6 +385,14 @@
"@babel/helper-validator-identifier" "^7.24.5"
to-fast-properties "^2.0.0"
"@babel/types@^7.25.9", "@babel/types@^7.26.5", "@babel/types@^7.26.7":
version "7.26.7"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.7.tgz#5e2b89c0768e874d4d061961f3a5a153d71dc17a"
integrity sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==
dependencies:
"@babel/helper-string-parser" "^7.25.9"
"@babel/helper-validator-identifier" "^7.25.9"
"@esbuild/aix-ppc64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz#a70f4ac11c6a1dfc18b8bbb13284155d933b9537"
@@ -951,19 +1104,10 @@
resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz"
integrity sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==
"@types/react-dom@18.2.1":
version "18.2.1"
resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.1.tgz"
integrity sha512-8QZEV9+Kwy7tXFmjJrp3XUKQSs9LTnE0KnoUb0YCguWBiNW0Yfb2iBMYZ08WPg35IR6P3Z0s00B15SwZnO26+w==
dependencies:
"@types/react" "*"
"@types/react-grid-layout@^1.3.4":
version "1.3.5"
resolved "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz"
integrity sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==
dependencies:
"@types/react" "*"
"@types/react-dom@^18.3.1":
version "18.3.5"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.5.tgz#45f9f87398c5dcea085b715c58ddcf1faf65f716"
integrity sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==
"@types/react-transition-group@^4.4.1":
version "4.4.10"
@@ -972,7 +1116,7 @@
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@18.2.0":
"@types/react@*":
version "18.2.0"
resolved "https://registry.npmjs.org/@types/react/-/react-18.2.0.tgz"
integrity sha512-0FLj93y5USLHdnhIhABk83rm8XEGA7kH3cr+YUlvxoUGp1xNt/DINUMvqPxLyOQMzLmZe8i4RTHbvb8MC7NmrA==
@@ -981,6 +1125,14 @@
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/react@^18.3.12":
version "18.3.18"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.18.tgz#9b382c4cd32e13e463f97df07c2ee3bbcd26904b"
integrity sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==
dependencies:
"@types/prop-types" "*"
csstype "^3.0.2"
"@types/resize-observer-browser@^0.1.7":
version "0.1.11"
resolved "https://registry.npmjs.org/@types/resize-observer-browser/-/resize-observer-browser-0.1.11.tgz"
@@ -1098,14 +1250,14 @@
"@rollup/pluginutils" "^4.1.1"
react-refresh "^0.10.0"
"@vitejs/plugin-react@^4.3.0":
version "4.3.1"
resolved "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.1.tgz"
integrity sha512-m/V2syj5CuVnaxcUJOQRel/Wr31FFXRFlnOoq1TVtkCxsY5veGMTEmpWHndrhB2U8ScHtCQB1e+4hWYExQc6Lg==
"@vitejs/plugin-react@^4.3.3":
version "4.3.4"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz#c64be10b54c4640135a5b28a2432330e88ad7c20"
integrity sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==
dependencies:
"@babel/core" "^7.24.5"
"@babel/plugin-transform-react-jsx-self" "^7.24.5"
"@babel/plugin-transform-react-jsx-source" "^7.24.1"
"@babel/core" "^7.26.0"
"@babel/plugin-transform-react-jsx-self" "^7.25.9"
"@babel/plugin-transform-react-jsx-source" "^7.25.9"
"@types/babel__core" "^7.20.5"
react-refresh "^0.14.2"
@@ -1324,6 +1476,16 @@ browserslist@^4.22.2, browserslist@^4.23.0:
node-releases "^2.0.14"
update-browserslist-db "^1.0.13"
browserslist@^4.24.0:
version "4.24.4"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.4.tgz#c6b2865a3f08bcb860a0e827389003b9fe686e4b"
integrity sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==
dependencies:
caniuse-lite "^1.0.30001688"
electron-to-chromium "^1.5.73"
node-releases "^2.0.19"
update-browserslist-db "^1.1.1"
call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7:
version "1.0.7"
resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz"
@@ -1346,9 +1508,14 @@ camelcase-css@^2.0.1:
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
caniuse-lite@^1.0.30001587, caniuse-lite@^1.0.30001599:
version "1.0.30001600"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001600.tgz"
integrity sha512-+2S9/2JFhYmYaDpZvo0lKkfvuKIglrx68MwOBqMGHhQsNkLjB5xtc/TGoEPs+MxjSyN/72qer2g97nzR641mOQ==
version "1.0.30001696"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001696.tgz"
integrity sha512-pDCPkvzfa39ehJtJ+OwGT/2yvT2SbjfHhiIW2LWOAcMQ7BzwxT/XuyUp4OTOd0XFWA6BKw0JalnBHgSi5DGJBQ==
caniuse-lite@^1.0.30001688:
version "1.0.30001697"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001697.tgz#040bbbb54463c4b4b3377c716b34a322d16e6fc7"
integrity sha512-GwNPlWJin8E+d7Gxq96jxM6w0w+VFeyyXRsjU58emtkYqnbwHqXm5uT2uCmO0RQE9htWknOP4xtBlLmM/gWxvQ==
chalk@^2.4.2:
version "2.4.2"
@@ -1401,12 +1568,7 @@ cliui@^8.0.1:
strip-ansi "^6.0.1"
wrap-ansi "^7.0.0"
clsx@^1.1.1:
version "1.2.1"
resolved "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
clsx@^2.0.0, clsx@^2.1.1:
clsx@^2.1.1:
version "2.1.1"
resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
@@ -1660,6 +1822,11 @@ electron-to-chromium@^1.4.668:
resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.722.tgz"
integrity sha512-5nLE0TWFFpZ80Crhtp4pIp8LXCztjYX41yUcV6b+bKR2PqzjskTMOOlBi1VjBHlvHwS+4gar7kNKOrsbsewEZQ==
electron-to-chromium@^1.5.73:
version "1.5.91"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.91.tgz#cf5567f6853062493242133aefd4dc8dc8440abd"
integrity sha512-sNSHHyq048PFmZY4S90ax61q+gLCs0X0YmcOII9wG9S2XwbVr+h4VW2wWhnbp/Eys3cCwTxVF292W3qPaxIapQ==
emoji-regex@^8.0.0:
version "8.0.0"
resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz"
@@ -1820,6 +1987,11 @@ escalade@^3.1.1:
resolved "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz"
integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==
escalade@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==
escape-string-regexp@^1.0.5:
version "1.0.5"
resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz"
@@ -1989,11 +2161,6 @@ fast-diff@^1.1.2:
resolved "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz"
integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==
fast-equals@^4.0.3:
version "4.0.3"
resolved "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz"
integrity sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==
fast-glob@^3.2.9, fast-glob@^3.3.0, fast-glob@^3.3.2:
version "3.3.2"
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz"
@@ -2597,6 +2764,11 @@ jsesc@^2.5.1:
resolved "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz"
integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==
jsesc@^3.0.2:
version "3.1.0"
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d"
integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==
json-buffer@3.0.1:
version "3.0.1"
resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz"
@@ -2814,6 +2986,11 @@ node-releases@^2.0.14:
resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz"
integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==
node-releases@^2.0.19:
version "2.0.19"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314"
integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz"
@@ -2975,6 +3152,11 @@ picocolors@^1, picocolors@^1.0.0:
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz"
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"
@@ -3139,7 +3321,7 @@ primereact@^10.6.5:
"@types/react-transition-group" "^4.4.1"
react-transition-group "^4.4.1"
prop-types@15.x, prop-types@^15.6.2, prop-types@^15.8.1:
prop-types@^15.6.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -3166,13 +3348,13 @@ react-dom@18.2.0:
loose-envify "^1.1.0"
scheduler "^0.23.0"
react-draggable@^4.0.3, react-draggable@^4.4.5:
version "4.4.6"
resolved "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz"
integrity sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==
react-dom@^18.3.1:
version "18.3.1"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4"
integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==
dependencies:
clsx "^1.1.1"
prop-types "^15.8.1"
loose-envify "^1.1.0"
scheduler "^0.23.2"
react-error-boundary@^4.0.13:
version "4.0.13"
@@ -3200,18 +3382,6 @@ react-flow-renderer@^10.3.17:
d3-zoom "^3.0.0"
zustand "^3.7.2"
react-grid-layout@^1.3.4:
version "1.4.4"
resolved "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.4.4.tgz"
integrity sha512-7+Lg8E8O8HfOH5FrY80GCIR1SHTn2QnAYKh27/5spoz+OHhMmEhU/14gIkRzJOtympDPaXcVRX/nT1FjmeOUmQ==
dependencies:
clsx "^2.0.0"
fast-equals "^4.0.3"
prop-types "^15.8.1"
react-draggable "^4.4.5"
react-resizable "^3.0.5"
resize-observer-polyfill "^1.5.1"
react-hook-form@^7.53.1:
version "7.53.1"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.53.1.tgz#3f2cd1ed2b3af99416a4ac674da2d526625add67"
@@ -3232,14 +3402,6 @@ react-refresh@^0.14.2:
resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz"
integrity sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==
react-resizable@^3.0.5:
version "3.0.5"
resolved "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz"
integrity sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==
dependencies:
prop-types "15.x"
react-draggable "^4.0.3"
react-transition-group@^4.4.1:
version "4.4.5"
resolved "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz"
@@ -3262,6 +3424,13 @@ react@18.2.0:
dependencies:
loose-envify "^1.1.0"
react@^18.3.1:
version "18.3.1"
resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"
integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==
dependencies:
loose-envify "^1.1.0"
reactflow@^11.11.4:
version "11.11.4"
resolved "https://registry.yarnpkg.com/reactflow/-/reactflow-11.11.4.tgz#e3593e313420542caed81aecbd73fb9bc6576653"
@@ -3321,11 +3490,6 @@ require-directory@^2.1.1:
resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz"
integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
resize-observer-polyfill@^1.5.1:
version "1.5.1"
resolved "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz"
integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
resolve-from@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz"
@@ -3445,6 +3609,13 @@ scheduler@^0.23.0:
dependencies:
loose-envify "^1.1.0"
scheduler@^0.23.2:
version "0.23.2"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3"
integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
dependencies:
loose-envify "^1.1.0"
semver@^6.3.1:
version "6.3.1"
resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz"
@@ -3801,6 +3972,14 @@ update-browserslist-db@^1.0.13:
escalade "^3.1.1"
picocolors "^1.0.0"
update-browserslist-db@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz#97e9c96ab0ae7bcac08e9ae5151d26e6bc6b5580"
integrity sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==
dependencies:
escalade "^3.2.0"
picocolors "^1.1.1"
uri-js@^4.2.2:
version "4.4.1"
resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz"

View File

@@ -53,6 +53,11 @@ public_api_disabled =
|> get_var_from_path_or_env("WANDERER_PUBLIC_API_DISABLED", "false")
|> String.to_existing_atom()
zkill_preload_disabled =
config_dir
|> get_var_from_path_or_env("WANDERER_ZKILL_PRELOAD_DISABLED", "false")
|> String.to_existing_atom()
map_subscriptions_enabled =
config_dir
|> get_var_from_path_or_env("WANDERER_MAP_SUBSCRIPTIONS_ENABLED", "false")
@@ -113,6 +118,7 @@ config :wanderer_app,
corp_id: System.get_env("WANDERER_CORP_ID", "-1") |> String.to_integer(),
corp_wallet: System.get_env("WANDERER_CORP_WALLET", ""),
public_api_disabled: public_api_disabled,
zkill_preload_disabled: zkill_preload_disabled,
map_subscriptions_enabled: map_subscriptions_enabled,
map_connection_auto_expire_hours: map_connection_auto_expire_hours,
map_connection_auto_eol_hours: map_connection_auto_eol_hours,

View File

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

View File

@@ -13,39 +13,48 @@ defmodule WandererApp.Application do
WandererAppWeb.Telemetry,
WandererApp.Vault,
WandererApp.Repo,
{Phoenix.PubSub, name: WandererApp.PubSub, adapter_name: Phoenix.PubSub.PG2},
{Finch, name: WandererApp.Finch},
{
Finch,
name: WandererApp.Finch,
pools: %{
default: [
size: 25, # number of connections per pool
count: 2, # number of pools (so total 50 connections)
]
}
},
WandererApp.Cache,
Supervisor.child_spec({Cachex, name: :system_static_info_cache},
id: :system_static_info_cache_worker
),
Supervisor.child_spec({Cachex, name: :ship_types_cache},
id: :ship_types_cache_worker
),
Supervisor.child_spec({Cachex, name: :character_cache}, id: :character_cache_worker),
Supervisor.child_spec({Cachex, name: :map_cache}, id: :map_cache_worker),
Supervisor.child_spec({Cachex, name: :character_state_cache},
id: :character_state_cache_worker
),
Supervisor.child_spec({Cachex, name: :system_static_info_cache}, id: :system_static_info_cache_worker),
Supervisor.child_spec({Cachex, name: :ship_types_cache}, id: :ship_types_cache_worker),
Supervisor.child_spec({Cachex, name: :character_cache}, id: :character_cache_worker),
Supervisor.child_spec({Cachex, name: :map_cache}, id: :map_cache_worker),
Supervisor.child_spec({Cachex, name: :character_state_cache}, id: :character_state_cache_worker),
WandererApp.Scheduler,
{Registry, keys: :unique, name: WandererApp.MapRegistry},
{Registry, keys: :unique, name: WandererApp.Character.TrackerRegistry},
{PartitionSupervisor,
child_spec: DynamicSupervisor, name: WandererApp.Map.DynamicSupervisors},
{PartitionSupervisor,
child_spec: DynamicSupervisor, name: WandererApp.Character.DynamicSupervisors},
{PartitionSupervisor, child_spec: DynamicSupervisor, name: WandererApp.Map.DynamicSupervisors},
{PartitionSupervisor, child_spec: DynamicSupervisor, name: WandererApp.Character.DynamicSupervisors},
WandererApp.Zkb.Supervisor,
WandererApp.Server.ServerStatusTracker,
WandererApp.Server.TheraDataFetcher,
WandererApp.Character.TrackerManager,
WandererApp.Map.Manager,
WandererApp.Map.ZkbDataFetcher,
WandererAppWeb.Presence,
WandererAppWeb.Endpoint
] ++ maybe_start_corp_wallet_tracker(WandererApp.Env.map_subscriptions_enabled?())
]
++ maybe_start_corp_wallet_tracker(WandererApp.Env.map_subscriptions_enabled?())
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: WandererApp.Supervisor]
Supervisor.start_link(children, opts)
@@ -59,8 +68,6 @@ defmodule WandererApp.Application do
end
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
WandererAppWeb.Endpoint.config_change(changed, removed)
@@ -72,5 +79,6 @@ defmodule WandererApp.Application do
WandererApp.StartCorpWalletTrackerTask
]
defp maybe_start_corp_wallet_tracker(_), do: []
defp maybe_start_corp_wallet_tracker(_),
do: []
end

View File

@@ -71,7 +71,7 @@ defmodule WandererApp.Character.Tracker do
{:ok, %{eve_id: eve_id}} = WandererApp.Character.get_character(character_id)
case WandererApp.Esi.get_character_info(eve_id) do
{:ok, info} ->
{:ok, _info} ->
{:ok, character_state} = WandererApp.Character.get_character_state(character_id)
update = maybe_update_corporation(character_state, eve_id |> String.to_integer())

View File

@@ -71,7 +71,7 @@ defmodule WandererApp.Character.TransactionsTracker do
@impl true
def handle_info(:shutdown, %Impl{} = state) do
Logger.debug("Shutting down character transaction tracker: #{inspect(state.character_id)}")
Logger.debug(fn -> "Shutting down character transaction tracker: #{inspect(state.character_id)}" end)
{:stop, :normal, state}
end

View File

@@ -11,6 +11,7 @@ defmodule WandererApp.Env do
def invites, do: get_key(:invites, false)
def map_subscriptions_enabled?, do: get_key(:map_subscriptions_enabled, false)
def public_api_disabled?, do: get_key(:public_api_disabled, false)
def zkill_preload_disabled?, do: get_key(:zkill_preload_disabled, false)
def wallet_tracking_enabled?, do: get_key(:wallet_tracking_enabled, false)
def admins, do: get_key(:admins, [])
def admin_username, do: get_key(:admin_username)
@@ -39,4 +40,12 @@ defmodule WandererApp.Env do
do: get_key(:map_connection_eol_expire_timeout_mins)
def get_key(key, default \\ nil), do: Application.get_env(@app, key, default)
@doc """
A single map containing environment variables
made available to react
"""
def to_client_env do
%{detailedKillsDisabled: zkill_preload_disabled?()}
end
end

View File

@@ -24,6 +24,9 @@ defmodule WandererApp.Esi do
defdelegate find_routes(map_id, origin, hubs, routes_settings), to: WandererApp.Esi.ApiClient
defdelegate search(character_eve_id, opts \\ []), to: WandererApp.Esi.ApiClient
defdelegate get_killmail(killmail_id, killmail_hash, opts \\ []), to: WandererApp.Esi.ApiClient
defdelegate set_autopilot_waypoint(
add_to_beginning,
clear_other_waypoints,

View File

@@ -289,6 +289,16 @@ defmodule WandererApp.Esi.ApiClient do
end
end
@decorate cacheable(
cache: Cache,
key: "killmail-#{killmail_id}-#{killmail_hash}",
opts: [ttl: @ttl]
)
def get_killmail(killmail_id, killmail_hash, opts \\ []) do
get("/killmails/#{killmail_id}/#{killmail_hash}/", _with_cache_opts(opts))
end
@decorate cacheable(
cache: Cache,
key: "info-#{eve_id}",
@@ -343,20 +353,29 @@ defmodule WandererApp.Esi.ApiClient do
def get_character_ship(character_eve_id, opts \\ []),
do: _get_character_auth_data(character_eve_id, "ship", opts)
def search(character_eve_id, opts \\ []),
do: _search(character_eve_id, opts[:params][:search], opts)
def search(character_eve_id, opts \\ []) do
search_val = to_string(opts[:params][:search] || "")
categories_val = to_string(opts[:params][:categories] || "character,alliance,corporation")
query_params = [
{"search", search_val},
{"categories", categories_val},
{"language", "en-us"},
{"strict", "false"},
{"datasource", "tranquility"}
]
merged_opts = Keyword.put(opts, :params, query_params)
_search(character_eve_id, search_val, categories_val, merged_opts)
end
@decorate cacheable(
cache: Cache,
key: "search-#{character_eve_id}-#{search |> Slug.slugify()}",
opts: [ttl: @ttl]
)
defp _search(character_eve_id, search, opts \\ []) when is_binary(search) do
_get_character_auth_data(
character_eve_id,
"search",
opts
)
cache: Cache,
key: "search-#{character_eve_id}-#{categories_val}-#{search_val |> Slug.slugify()}",
opts: [ttl: @ttl]
)
defp _search(character_eve_id, search_val, categories_val, merged_opts) do
_get_character_auth_data(character_eve_id, "search", merged_opts)
end
defp _remove_intersection(pairs_arr) do

View File

@@ -70,9 +70,14 @@ defmodule WandererApp.Map do
def get_characters_limit(map_id),
do: {:ok, map_id |> get_map!() |> Map.get(:characters_limit, 50)}
def is_subscription_active?(map_id) do
def is_subscription_active?(map_id),
do: is_subscription_active?(map_id, WandererApp.Env.map_subscriptions_enabled?())
def is_subscription_active?(_map_id, false), do: {:ok, true}
def is_subscription_active?(map_id, _map_subscriptions_enabled) do
{:ok, %{plan: plan}} = WandererApp.Map.SubscriptionManager.get_active_map_subscription(map_id)
not WandererApp.Env.map_subscriptions_enabled?() || plan != :alpha
{:ok, plan != :alpha}
end
def get_options(map_id),

View File

@@ -6,114 +6,202 @@ 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
zkill_preload_disabled = WandererApp.Env.zkill_preload_disabled?()
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
{:ok, is_subscription_active} = map_id |> WandererApp.Map.is_subscription_active?()
can_preload_zkill = not zkill_preload_disabled && is_subscription_active
if can_preload_zkill 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
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(fn -> "[ZkbDataFetcher] No changes in detailed kills for map_id=#{map_id}" end)
:ok
else
# Build new details for each changed system
updated_details_map =
Enum.reduce(changed_systems, old_details_map, fn system_id, acc ->
kill_ids =
new_ids_map
|> Map.fetch!(system_id)
|> MapSet.to_list()
system_ids ->
:ok =
WandererApp.Cache.put("map_#{map_id}:zkb_kills", new_kills_map,
ttl: @store_map_kills_timeout
)
kill_details =
kill_ids
|> Enum.map(&KillsCache.get_killmail/1)
|> Enum.reject(&is_nil/1)
Map.put(acc, system_id, kill_details)
end)
updated_ids_map =
Enum.reduce(changed_systems, old_ids_map, fn system_id, acc ->
new_ids_list = new_ids_map[system_id] |> MapSet.to_list()
Map.put(acc, system_id, new_ids_list)
end)
WandererApp.Cache.put("map_#{map_id}:zkb_ids", updated_ids_map,
ttl: :timer.hours(KillsCache.killmail_ttl())
)
WandererApp.Cache.put("map_#{map_id}:zkb_detailed_kills", updated_details_map,
ttl: :timer.hours(KillsCache.killmail_ttl())
)
changed_data = Map.take(updated_details_map, changed_systems)
@pubsub_client.broadcast!(WandererApp.PubSub, map_id, %{
event: :kills_updated,
payload: new_kills_map |> Map.take(system_ids)
event: :detailed_kills_updated,
payload: changed_data
})
:ok
end
end)
end
defp maybe_broadcast_map_kills(new_kills_map, map_id) do
{:ok, old_kills_map} = WandererApp.Cache.lookup("map_#{map_id}:zkb_kills", %{})
# Use the union of keys from both the new and old maps
all_system_ids = Map.keys(Map.merge(new_kills_map, old_kills_map))
changed_system_ids =
Enum.filter(all_system_ids, fn system_id ->
new_kills_count = Map.get(new_kills_map, system_id, 0)
old_kills_count = Map.get(old_kills_map, system_id, 0)
new_kills_count != old_kills_count and
(new_kills_count > 0 or (old_kills_count > 0 and new_kills_count == 0))
end)
if changed_system_ids == [] do
:ok
else
:ok =
WandererApp.Cache.put("map_#{map_id}:zkb_kills", new_kills_map,
ttl: @store_map_kills_timeout
)
payload = Map.take(new_kills_map, changed_system_ids)
@pubsub_client.broadcast!(WandererApp.PubSub, map_id, %{
event: :kills_updated,
payload: payload
})
:ok
end
end
defp with_started_map(map_id, label \\ "operation", fun) when is_function(fun, 0) do
if WandererApp.Cache.lookup!("map_#{map_id}:started", false) do
fun.()
else
Logger.debug(fn -> "[ZkbDataFetcher] Map #{map_id} not started => skipping #{label}" end)
:ok
end
end
end

View File

@@ -393,7 +393,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do
end
error ->
Logger.debug("Skip adding system: #{inspect(error, pretty: true)}")
Logger.debug(fn -> "Skip adding system: #{inspect(error, pretty: true)}" end)
:ok
end
end

View File

@@ -61,17 +61,15 @@ defmodule WandererApp.Structure do
end
defp parse_end_time(str) when is_binary(str) do
# Log everything we can about the incoming string
Logger.debug("[parse_end_time] raw input => #{inspect(str)} (length=#{String.length(str)})")
Logger.debug(fn -> "[parse_end_time] raw input => #{inspect(str)} (length=#{String.length(str)})" end)
if String.trim(str) == "" do
Logger.debug("[parse_end_time] It's empty (or whitespace only). Returning nil.")
nil
else
# Attempt to parse
case DateTime.from_iso8601(str) do
{:ok, dt, _offset} ->
Logger.debug("[parse_end_time] Successfully parsed => #{inspect(dt)}")
dt
{:error, reason} ->

View File

@@ -0,0 +1,311 @@
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)
active_maps_with_subscription = get_active_maps_with_subscription(last_active_maps)
# Gather systems from those maps
system_tuples = gather_visible_systems(active_maps_with_subscription)
unique_systems = Enum.uniq(system_tuples)
Logger.debug(fn -> "
[KillsPreloader] Found #{length(unique_systems)} unique systems \
across #{length(last_active_maps)} map(s)
" end)
# ---- 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 get_active_maps_with_subscription(maps) do
maps
|> Enum.filter(fn map ->
{:ok, is_subscription_active} = map.id |> WandererApp.Map.is_subscription_active?()
is_subscription_active
end)
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(fn -> "[KillsPreloader] Quick fetch => system=#{system_id}, hours=#{hours}, limit=#{limit}" end)
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(fn -> "[KillsPreloader] Expanded fetch => system=#{system_id}, hours=#{hours}, limit=#{limit} (forcing refresh)" end)
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(fn -> "[KillsPreloader] Expanding to #{@expanded_hours}h => system=#{system_id}, need=#{needed} more kills" end)
case KillsProvider.Fetcher.fetch_kills_for_system(system_id, @expanded_hours, state,
limit: needed,
force: true
) do
{:ok, _kills_24h, updated_state2} ->
final_kills =
KillsCache.fetch_cached_kills(system_id)
|> Enum.take(limit)
{:ok, final_kills, updated_state2}
{:error, reason2, updated_state2} ->
Logger.warning(
"[KillsPreloader] #{@expanded_hours}h fetch failed => system=#{system_id}, reason=#{inspect(reason2)}"
)
{:error, reason2, updated_state2}
end
else
{:ok, kills_1h, state}
end
end
defp reduce_task_result(pass_type, task_result, acc_state, acc_map) do
case task_result do
{:ok, {:ok, sys_id, kills, updated_state}} ->
# Merge calls count from updated_state into acc_state
new_state = merge_calls_count(acc_state, updated_state)
new_map = Map.put(acc_map, sys_id, kills)
{new_state, new_map}
{:ok, {:error, reason, updated_state}} ->
log_failed_task(pass_type, reason)
new_state = merge_calls_count(acc_state, updated_state)
{new_state, acc_map}
{:error, reason} ->
Logger.error("[KillsPreloader] #{pass_type} fetch task crashed => #{inspect(reason)}")
{acc_state, acc_map}
end
end
defp log_failed_task(:quick, reason),
do: Logger.warning("[KillsPreloader] Quick fetch task failed => #{inspect(reason)}")
defp log_failed_task(:expanded, reason),
do: Logger.error("[KillsPreloader] Expanded fetch task failed => #{inspect(reason)}")
defp broadcast_all_kills(kills_map, pass_type) do
Logger.info(
"[KillsPreloader] Broadcasting kills => #{map_size(kills_map)} systems (#{pass_type})"
)
Phoenix.PubSub.broadcast!(
WandererApp.PubSub,
"zkb_preload",
%{
event: :detailed_kills_updated,
payload: kills_map,
fetch_type: pass_type
}
)
end
defp merge_calls_count(%{calls_count: c1} = st1, %{calls_count: c2}),
do: %{st1 | calls_count: c1 + c2}
defp merge_calls_count(st1, _other),
do: st1
defp pass_timeout_ms(:quick), do: :timer.minutes(2)
defp pass_timeout_ms(:expanded), do: :timer.minutes(5)
defp measure_execution_time(fun) when is_function(fun, 0) do
start = System.monotonic_time()
result = fun.()
finish = System.monotonic_time()
ms = System.convert_time_unit(finish - start, :native, :millisecond)
{ms, result}
end
end

View File

@@ -1,127 +1,29 @@
defmodule WandererApp.Zkb.KillsProvider do
@moduledoc false
use Fresh
require Logger
alias WandererApp.Zkb.KillsProvider.Websocket
defstruct [:connected]
require Logger
def handle_connect(status, headers, state),
do: Websocket.handle_connect(status, headers, state)
@heartbeat_interval 1_000
def handle_in(frame, state),
do: Websocket.handle_in(frame, state)
def handle_connect(_status, _headers, state) do
Logger.debug(fn ->
"#{__MODULE__}: connected to kills stream"
end)
def handle_control(msg, state),
do: Websocket.handle_control(msg, state)
handle_subscribe("killstream", %__MODULE__{state | connected: true})
end
def handle_info(msg, state),
do: Websocket.handle_info(msg, state)
def handle_in({:text, frame}, state) do
frame
|> Jason.decode!()
|> handle_websocket(state)
end
def handle_disconnect(code, reason, state),
do: Websocket.handle_disconnect(code, reason, state)
def handle_control({:ping, _message}, state) do
Process.send_after(self(), :heartbeat, @heartbeat_interval)
{:ok, state}
end
def handle_error(err, state),
do: Websocket.handle_error(err, state)
def handle_control(_event, state) do
{:ok, state}
end
def handle_info(:heartbeat, state) do
payload =
Jason.encode!(%{
"action" => "pong"
})
{:reply, {:text, payload}, state}
end
def handle_info(_message, state) do
{:ok, state}
end
def handle_info(_message, _ws, state) do
{:ok, state}
end
defp handle_subscribe(channel, state) do
Logger.debug(fn ->
"#{__MODULE__} subscribe: #{inspect(channel, pretty: true)}"
end)
payload =
Jason.encode!(%{
"action" => "sub",
"channel" => channel
})
{:reply, {:text, payload}, state}
end
defp handle_websocket(message, state) do
case message |> parse_message() do
nil ->
{:ok, state}
%{solar_system_id: solar_system_id, kill_time: kill_time} = _message ->
case DateTime.diff(DateTime.utc_now(), kill_time, :hour) do
0 ->
WandererApp.Cache.incr("zkb_kills_#{solar_system_id}", 1,
default: 0,
ttl: :timer.hours(1)
)
_ ->
:ok
end
end
{:ok, state}
end
def handle_disconnect(1002, reason, _state) do
Logger.warning(fn ->
"Connection to socket lost by #{inspect(reason, pretty: true)}; reconnecting..."
end)
:reconnect
end
def handle_disconnect(_code, reason, _state) do
Logger.warning(fn ->
"Connection to socket lost by #{inspect(reason, pretty: true)}; closing..."
end)
:reconnect
end
def handle_error({error, _reason}, state)
when error in [:encoding_failed, :casting_failed],
do: {:ignore, state}
def handle_error(_error, _state), do: :reconnect
def handle_terminate(reason, _state) do
Logger.warning(fn -> "Terminating client process with reason : #{inspect(reason)}" end)
end
defp parse_message(
%{
"solar_system_id" => solar_system_id,
"killmail_time" => killmail_time
} = _message
) do
{:ok, kill_time, _} = DateTime.from_iso8601(killmail_time)
%{
solar_system_id: solar_system_id,
kill_time: kill_time
}
end
defp parse_message(_message), do: nil
def handle_terminate(reason, state),
do: Websocket.handle_terminate(reason, state)
end

View File

@@ -8,15 +8,27 @@ defmodule WandererApp.Zkb.Supervisor do
end
def init(_init_args) do
children = [
{WandererApp.Zkb.KillsProvider,
uri: "wss://zkillboard.com/websocket/",
state: %WandererApp.Zkb.KillsProvider{},
opts: [
name: {:local, :zkb_kills_provider},
mint_upgrade_opts: [Mint.WebSocket.PerMessageDeflate]
]}
]
preloader_child =
unless WandererApp.Env.zkill_preload_disabled?() do
{WandererApp.Zkb.KillsPreloader, []}
end
children =
[
{
WandererApp.Zkb.KillsProvider,
uri: "wss://zkillboard.com/websocket/",
state: %WandererApp.Zkb.KillsProvider{
connected: false
},
opts: [
name: {:local, :zkb_kills_provider},
mint_upgrade_opts: [Mint.WebSocket.PerMessageDeflate]
]
},
preloader_child
]
|> Enum.reject(&is_nil/1)
Supervisor.init(children, strategy: :one_for_one)
end

Some files were not shown because too many files have changed in this diff Show More