Add hover tooltips for local counter and kills bookmark (#130)

* feat: add local pilots and kills display on hover
This commit is contained in:
guarzo
2025-02-04 10:19:13 -07:00
committed by GitHub
parent ac3c7e0c44
commit 55465688c8
37 changed files with 942 additions and 550 deletions

View File

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

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-blue: #5a7d9a;
$pastel-pink: #d291bc; $pastel-pink: #d291bc;
$pastel-green: #88b04b;
$pastel-yellow: #ffdd59;
$dark-bg: #2d2d2d; $dark-bg: #2d2d2d;
$text-color: #ffffff; $text-color: #ffffff;
$tooltip-bg: #202020; $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 { .RootCustomNode {
display: flex; display: flex;
width: 130px; width: 130px;
height: 34px; height: 34px;
font-family: var(--rf-node-font-family, inherit) !important;
font-weight: var(--rf-node-font-weight, inherit) !important;
flex-direction: column; flex-direction: column;
padding: 2px 6px; padding: 2px 6px;
font-size: 10px; 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); box-shadow: 0 0 5px rgba($dark-bg, 0.5);
border: 1px solid darken($pastel-blue, 10%); border: 1px solid darken($pastel-blue, 10%);
border-radius: 5px; border-radius: 5px;
@@ -92,14 +88,6 @@ $custom-name: #93C5FD;
box-shadow: 0 0 10px #9a1af1c2; 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 { &.eve-system-status-home {
border: 1px solid var(--eve-solar-system-status-color-home-dark30); border: 1px solid var(--eve-solar-system-status-color-home-dark30);
background-image: linear-gradient( background-image: linear-gradient(
@@ -178,8 +166,6 @@ $custom-name: #93C5FD;
padding-left: 3px; padding-left: 3px;
padding-right: 3px; padding-right: 3px;
//background-color: #833ca4;
&:not(:first-child) { &:not(:first-child) {
box-shadow: inset 4px -3px 4px rgba(0, 0, 0, 0.3); box-shadow: inset 4px -3px 4px rgba(0, 0, 0, 0.3);
} }
@@ -266,26 +252,18 @@ $custom-name: #93C5FD;
.TagTitle { .TagTitle {
font-size: 11px; font-size: 11px;
font-weight: medium; font-weight: 500;
text-shadow: 0 0 2px rgba(231, 146, 52, 0.73); text-shadow: 0 0 2px rgba(231, 146, 52, 0.73);
color: var(--rf-tag-color, #38BDF8); color: var(--rf-tag-color, #38BDF8);
} }
/* Firefox kostyl */ /* Firefox kostyl */
@-moz-document url-prefix() { @-moz-document url-prefix() {
.classSystemName { .classSystemName {
font-family: inherit !important;
font-weight: bold; font-weight: bold;
} }
} }
.classSystemName {
//font-weight: bold;
}
.solarSystemName {
}
} }
.BottomRow { .BottomRow {
@@ -294,22 +272,23 @@ $custom-name: #93C5FD;
align-items: center; align-items: center;
height: 19px; height: 19px;
.localCounter { .hasLocalCounter {
display: flex; margin-right: 1.25rem;
//align-items: center; &.countAbove9 {
gap: 2px; margin-right: 1.5rem;
& > i {
position: relative;
top: 1px;
} }
}
& > span { .lockIcon {
font-size: 9px; font-size: 0.45rem;
line-height: 9px; font-weight: bold;
font-weight: 500; position: relative;
//margin-top: 1px; }
}
.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 { memo } from 'react';
import { MapSolarSystemType } from '../../map.types'; import { MapSolarSystemType } from '../../map.types';
import { Handle, Position, NodeProps } from 'reactflow'; import { Handle, NodeProps, Position } from 'reactflow';
import clsx from 'clsx'; import clsx from 'clsx';
import classes from './SolarSystemNodeDefault.module.scss'; import classes from './SolarSystemNodeDefault.module.scss';
import { PrimeIcons } from 'primereact/api'; import { PrimeIcons } from 'primereact/api';
import { useSolarSystemNode } from '../../hooks/useSolarSystemNode'; import { useLocalCounter, useSolarSystemNode } from '../../hooks/useSolarSystemLogic';
import { import {
EFFECT_BACKGROUND_STYLES,
MARKER_BOOKMARK_BG_STYLES, MARKER_BOOKMARK_BG_STYLES,
STATUS_CLASSES, STATUS_CLASSES,
EFFECT_BACKGROUND_STYLES,
} from '@/hooks/Mapper/components/map/constants'; } from '@/hooks/Mapper/components/map/constants';
import { WormholeClassComp } from '@/hooks/Mapper/components/map/components/WormholeClassComp'; import { WormholeClassComp } from '@/hooks/Mapper/components/map/components/WormholeClassComp';
import { UnsplashedSignature } from '@/hooks/Mapper/components/map/components/UnsplashedSignature'; import { UnsplashedSignature } from '@/hooks/Mapper/components/map/components/UnsplashedSignature';
import { LocalCounter } from './SolarSystemLocalCounter';
import { KillsCounter } from './SolarSystemKillsCounter';
export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>) => { export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>) => {
const nodeVars = useSolarSystemNode(props); const nodeVars = useSolarSystemNode(props);
const { localCounterCharacters } = useLocalCounter(nodeVars);
return ( return (
<> <>
@@ -22,7 +25,7 @@ export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>
<div className={classes.Bookmarks}> <div className={classes.Bookmarks}>
{nodeVars.labelCustom !== '' && ( {nodeVars.labelCustom !== '' && (
<div className={clsx(classes.Bookmark, MARKER_BOOKMARK_BG_STYLES.custom)}> <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> </div>
)} )}
@@ -32,13 +35,19 @@ export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>
</div> </div>
)} )}
{nodeVars.killsCount && ( {nodeVars.killsCount && nodeVars.killsCount > 0 && nodeVars.solarSystemId && (
<div className={clsx(classes.Bookmark, MARKER_BOOKMARK_BG_STYLES[nodeVars.killsActivityType!])}> <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)}> <div className={clsx(classes.BookmarkWithIcon)}>
<span className={clsx(PrimeIcons.BOLT, classes.icon)} /> <span className={clsx(PrimeIcons.BOLT, classes.icon)} />
<span className={clsx(classes.text)}>{nodeVars.killsCount}</span> <span className={clsx(classes.text)}>{nodeVars.killsCount}</span>
</div> </div>
</div> </KillsCounter>
)} )}
{nodeVars.labelsInfo.map(x => ( {nodeVars.labelsInfo.map(x => (
@@ -53,10 +62,8 @@ export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>
className={clsx( className={clsx(
classes.RootCustomNode, classes.RootCustomNode,
nodeVars.regionClass && classes[nodeVars.regionClass], nodeVars.regionClass && classes[nodeVars.regionClass],
classes[STATUS_CLASSES[nodeVars.status]], nodeVars.status !== undefined ? classes[STATUS_CLASSES[nodeVars.status]] : '',
{ { [classes.selected]: nodeVars.selected },
[classes.selected]: nodeVars.selected,
},
)} )}
> >
{nodeVars.visible && ( {nodeVars.visible && (
@@ -88,7 +95,7 @@ export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>
{nodeVars.isWormhole && ( {nodeVars.isWormhole && (
<div className={classes.statics}> <div className={classes.statics}>
{nodeVars.sortedStatics.map(whClass => ( {nodeVars.sortedStatics.map(whClass => (
<WormholeClassComp key={whClass} id={whClass} /> <WormholeClassComp key={String(whClass)} id={String(whClass)} />
))} ))}
</div> </div>
)} )}
@@ -114,24 +121,15 @@ export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>
{nodeVars.isWormhole && !nodeVars.customName && <div />} {nodeVars.isWormhole && !nodeVars.customName && <div />}
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<div className="flex gap-1 items-center"> <div
{nodeVars.locked && ( className={clsx('flex items-center gap-1', {
<i className={PrimeIcons.LOCK} style={{ fontSize: '0.45rem', fontWeight: 'bold' }} /> [classes.hasLocalCounter]: nodeVars.charactersInSystem.length > 0,
)} [classes.countAbove9]: nodeVars.charactersInSystem.length > 9,
})}
{nodeVars.hubs.includes(nodeVars.solarSystemId.toString()) && ( >
<i className={PrimeIcons.MAP_MARKER} style={{ fontSize: '0.45rem', fontWeight: 'bold' }} /> {nodeVars.locked && <i className={clsx(PrimeIcons.LOCK, classes.lockIcon)} />}
)} {nodeVars.hubs.includes(nodeVars.solarSystemId) && (
<i className={clsx(PrimeIcons.MAP_MARKER, classes.mapMarker)} />
{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> </div>
</div> </div>
@@ -145,7 +143,7 @@ export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>
{nodeVars.unsplashedLeft.length > 0 && ( {nodeVars.unsplashedLeft.length > 0 && (
<div className={classes.Unsplashed}> <div className={classes.Unsplashed}>
{nodeVars.unsplashedLeft.map(sig => ( {nodeVars.unsplashedLeft.map(sig => (
<UnsplashedSignature key={sig.sig_id} signature={sig} /> <UnsplashedSignature key={sig.eve_id} signature={sig} />
))} ))}
</div> </div>
)} )}
@@ -153,14 +151,14 @@ export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>
{nodeVars.unsplashedRight.length > 0 && ( {nodeVars.unsplashedRight.length > 0 && (
<div className={clsx(classes.Unsplashed, classes['Unsplashed--right'])}> <div className={clsx(classes.Unsplashed, classes['Unsplashed--right'])}>
{nodeVars.unsplashedRight.map(sig => ( {nodeVars.unsplashedRight.map(sig => (
<UnsplashedSignature key={sig.sig_id} signature={sig} /> <UnsplashedSignature key={sig.eve_id} signature={sig} />
))} ))}
</div> </div>
)} )}
</> </>
)} )}
<div onMouseDownCapture={nodeVars.dbClick} className={classes.Handlers}> <div onMouseDownCapture={e => nodeVars.dbClick(e)} className={classes.Handlers}>
<Handle <Handle
type="source" type="source"
className={clsx(classes.Handle, classes.HandleTop, { className={clsx(classes.Handle, classes.HandleTop, {
@@ -202,6 +200,11 @@ export const SolarSystemNodeDefault = memo((props: NodeProps<MapSolarSystemType>
id="d" id="d"
/> />
</div> </div>
<LocalCounter
hasUserCharacters={nodeVars.hasUserCharacters}
localCounterCharacters={localCounterCharacters}
classes={classes}
/>
</> </>
); );
}); });

View File

@@ -1,91 +1,6 @@
@import './SolarSystemNodeDefault.module.scss'; @import './SolarSystemNodeDefault.module.scss';
/* --------------------------- /* ---------------------------------------------
Only override what's different Only override what's different from the base
--------------------------- */ Currently none required
---------------------------------------------- */
/* 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;
}
}
}

View File

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

View File

@@ -6,6 +6,7 @@ export function useBackgroundVars(themeName?: string) {
const [gap, setGap] = useState<number>(16); const [gap, setGap] = useState<number>(16);
const [size, setSize] = useState<number>(1); const [size, setSize] = useState<number>(1);
const [color, setColor] = useState('#81818b'); const [color, setColor] = useState('#81818b');
const [snapSize, setSnapSize] = useState<number>(25);
useEffect(() => { useEffect(() => {
// match any element whose entire `class` attribute ends with "-theme" // 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 cssVarGap = style.getPropertyValue('--rf-bg-gap');
const cssVarSize = style.getPropertyValue('--rf-bg-size'); const cssVarSize = style.getPropertyValue('--rf-bg-size');
const cssVarSnapSize = style.getPropertyValue('--rf-snap-size');
const cssColor = style.getPropertyValue('--rf-bg-pattern-color'); const cssColor = style.getPropertyValue('--rf-bg-pattern-color');
const gapNum = parseInt(cssVarGap, 10) || 16; const gapNum = parseInt(cssVarGap, 10) || 16;
const sizeNum = parseInt(cssVarSize, 10) || 1; const sizeNum = parseInt(cssVarSize, 10) || 1;
const snapSize = parseInt(cssVarSnapSize, 10) || 25; //react-flow default
setVariant(finalVariant); setVariant(finalVariant);
setGap(gapNum); setGap(gapNum);
setSize(sizeNum); setSize(sizeNum);
setColor(cssColor); setColor(cssColor);
setSnapSize(snapSize);
}, [themeName]); }, [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 // do nothing here
break; break;
case Commands.detailedKillsUpdated:
// do nothing here
break;
default: default:
console.warn(`Map handlers: Unknown command: ${type}`, data); console.warn(`Map handlers: Unknown command: ${type}`, data);
break; 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 { getSystemClassStyles, prepareUnsplashedChunks } from '@/hooks/Mapper/components/map/helpers';
import { sortWHClasses } from '@/hooks/Mapper/helpers'; import { sortWHClasses } from '@/hooks/Mapper/helpers';
import { LabelsManager } from '@/hooks/Mapper/utils/labelsManager'; 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'; 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 <= 5) return 'activityNormal';
if (count <= 30) return 'activityWarn'; if (count <= 30) return 'activityWarn';
return 'activityDanger'; return 'activityDanger';
@@ -26,12 +33,25 @@ const SpaceToClass: Record<string, string> = {
[Spaces.Gallente]: 'Gallente', [Spaces.Gallente]: 'Gallente',
}; };
function sortedLabels(labels: string[]) { function sortedLabels(labels: string[]): LabelInfo[] {
if (!labels) return []; 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 { id, data, selected } = props;
const { const {
system_static_info, system_static_info,
@@ -71,7 +91,6 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>) {
const { const {
data: { data: {
characters, characters,
presentCharacters,
wormholesData, wormholesData,
hubs, hubs,
kills, kills,
@@ -87,15 +106,14 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>) {
const visible = useMemo(() => visibleNodes.has(id), [id, visibleNodes]); const visible = useMemo(() => visibleNodes.has(id), [id, visibleNodes]);
const systemSignatures = useMemo( const systemSigs = useMemo(
() => mapSystemSignatures[solar_system_id] || system_signatures, () => mapSystemSignatures[solar_system_id] || system_signatures,
[system_signatures, solar_system_id, mapSystemSignatures], [system_signatures, solar_system_id, mapSystemSignatures],
); );
const charactersInSystem = useMemo(() => { const charactersInSystem = useMemo(() => {
return characters.filter(c => c.location?.solar_system_id === solar_system_id).filter(c => c.online); return characters.filter(c => c.location?.solar_system_id === solar_system_id && c.online);
// eslint-disable-next-line }, [characters, solar_system_id]);
}, [characters, presentCharacters, solar_system_id]);
const isWormhole = isWormholeSpace(system_class); const isWormhole = isWormholeSpace(system_class);
@@ -136,52 +154,65 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>) {
const space = showKSpaceBG ? REGIONS_MAP[region_id] : ''; const space = showKSpaceBG ? REGIONS_MAP[region_id] : '';
const regionClass = showKSpaceBG ? SpaceToClass[space] : null; const regionClass = showKSpaceBG ? SpaceToClass[space] : null;
const temporaryName = useMemo(() => { const computedTemporaryName = useMemo(() => {
if (!isTempSystemNameEnabled) { if (!isTempSystemNameEnabled) {
return ''; return '';
} }
if (isShowLinkedSigIdTempName && linkedSigPrefix) { if (isShowLinkedSigIdTempName && linkedSigPrefix) {
return temporary_name ? `${linkedSigPrefix}${temporary_name}` : `${linkedSigPrefix}${solar_system_name}`; return temporary_name ? `${linkedSigPrefix}${temporary_name}` : `${linkedSigPrefix}${solar_system_name}`;
} }
return temporary_name; return temporary_name;
}, [isShowLinkedSigIdTempName, isTempSystemNameEnabled, linkedSigPrefix, solar_system_name, temporary_name]); }, [isShowLinkedSigIdTempName, isTempSystemNameEnabled, linkedSigPrefix, solar_system_name, temporary_name]);
const systemName = useMemo(() => { const systemName = useMemo(() => {
if (isTempSystemNameEnabled && temporaryName) { if (isTempSystemNameEnabled && computedTemporaryName) {
return temporaryName; return computedTemporaryName;
} }
return solar_system_name; 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(() => { const [unsplashedLeft, unsplashedRight] = useMemo(() => {
if (!isShowUnsplashedSignatures) { if (!isShowUnsplashedSignatures) {
return [[], []]; return [[], []];
} }
return prepareUnsplashedChunks( return prepareUnsplashedChunks(
systemSignatures systemSigs
.filter(s => s.group === 'Wormhole' && !s.linked_system) .filter(s => s.group === 'Wormhole' && !s.linked_system)
.map(s => ({ .map(s => ({
eve_id: s.eve_id, eve_id: s.eve_id,
type: s.type, type: s.type,
custom_info: s.custom_info, 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, id,
selected, selected,
visible, visible,
isWormhole, isWormhole,
classTitleColor, classTitleColor,
killsCount, killsCount,
killsActivityType, killsActivityType,
hasUserCharacters, hasUserCharacters,
userCharacters,
showHandlers, showHandlers,
regionClass, regionClass,
systemName, systemName,
@@ -195,10 +226,10 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>) {
sortedStatics, sortedStatics,
effectName: effect_name, effectName: effect_name,
regionName: region_name, regionName: region_name,
solarSystemId: solar_system_id, solarSystemId: solar_system_id.toString(),
solarSystemName: solar_system_name, solarSystemName: solar_system_name,
locked, locked,
hubs, hubs: hubsAsStrings,
name: name, name: name,
isConnecting, isConnecting,
hoverNodeId, hoverNodeId,
@@ -207,7 +238,7 @@ export function useSolarSystemNode(props: NodeProps<MapSolarSystemType>) {
unsplashedRight, unsplashedRight,
isThickConnections, isThickConnections,
classTitle: class_title, classTitle: class_title,
temporaryName: temporary_name, temporaryName: computedTemporaryName,
}; };
return nodeVars; return nodeVars;
@@ -230,24 +261,22 @@ export interface SolarSystemNodeVars {
isShattered: boolean; isShattered: boolean;
tag?: string | null; tag?: string | null;
status?: number; status?: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any labelsInfo: LabelInfo[];
labelsInfo: Array<any>; dbClick: (event: React.MouseEvent<HTMLDivElement>) => void;
dbClick: (event?: void) => void;
sortedStatics: Array<string | number>; sortedStatics: Array<string | number>;
effectName: string | null; effectName: string | null;
regionName: string | null; regionName: string | null;
solarSystemId: number; solarSystemId: string;
solarSystemName: string | null; solarSystemName: string | null;
locked: boolean; locked: boolean;
hubs: string[] | number[]; hubs: string[];
name: string | null; name: string | null;
isConnecting: boolean; isConnecting: boolean;
hoverNodeId: string | null; hoverNodeId: string | null;
charactersInSystem: Array<CharacterTypeRaw>; charactersInSystem: Array<CharacterTypeRaw>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any userCharacters: string[];
unsplashedLeft: Array<any>; unsplashedLeft: Array<SystemSignature>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any unsplashedRight: Array<SystemSignature>;
unsplashedRight: Array<any>;
isThickConnections: boolean; isThickConnections: boolean;
classTitle: string | null; classTitle: string | null;
temporaryName?: string | null; temporaryName?: string | null;

View File

@@ -11,7 +11,8 @@
--rf-tag-color: #38BDF8; --rf-tag-color: #38BDF8;
--rf-region-name: #D6D3D1; --rf-region-name: #D6D3D1;
--rf-custom-name: #93C5FD; --rf-custom-name: #93C5FD;
--rf-node-font-family: 'Shentox', 'Rogan', sans-serif !important;
--rf-node-font-weight: 500;
--rf-bg-variant: "dots"; --rf-bg-variant: "dots";
--rf-bg-gap: 15; --rf-bg-gap: 15;
@@ -28,4 +29,8 @@
--tooltip-bg: #202020; --tooltip-bg: #202020;
--window-corner: #72716f; --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'); @import url('https://fonts.googleapis.com/css2?family=Oxygen:wght@300;400;700&display=swap');
.pathfinder-theme { .pathfinder-theme {
/* -- Override values from the default theme -- */
--rf-bg-color: #000000; --rf-bg-color: #000000;
--rf-soft-bg-color: #282828; --rf-soft-bg-color: #282828;
--rf-node-bg-color: #202020;
--rf-node-soft-bg-color: #313335; --rf-node-soft-bg-color: #313335;
--rf-node-font-weight: bold; --rf-node-font-weight: bold;
--rf-text-color: #adadad; --rf-text-color: #adadad;
--rf-region-name: var(--rf-text-color); --rf-region-name: var(--rf-text-color);
--rf-custom-name: var(--rf-text-color); --rf-custom-name: var(--rf-text-color);
--tooltip-bg: #202020;
--rf-bg-variant: "lines"; --rf-bg-variant: "lines";
--rf-bg-gap: 32; --rf-bg-gap: 34;
--rf-bg-size: 1; --rf-snap-size: 17;
--rf-bg-pattern-color: #313131; --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-pulsar: #428bca;
--eve-effect-magnetar: #e06fdf; --eve-effect-magnetar: #e06fdf;
--eve-effect-wolfRayet: #e28a0d; --eve-effect-wolfRayet: #e28a0d;
@@ -38,14 +41,4 @@
--eve-wh-type-color-c6: #d9534f; --eve-wh-type-color-c6: #d9534f;
--eve-wh-type-color-c13: #7986cb; --eve-wh-type-color-c13: #7986cb;
--eve-wh-type-color-drifter: #44aa82; --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,71 +1,25 @@
import { useCallback, useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { Widget } from '@/hooks/Mapper/components/mapInterface/components'; import { Widget } from '@/hooks/Mapper/components/mapInterface/components';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { VirtualScroller, VirtualScrollerTemplateOptions } from 'primereact/virtualscroller';
import clsx from 'clsx'; import clsx from 'clsx';
import classes from './LocalCharacters.module.scss'; import { LayoutEventBlocker, WdCheckbox } from '@/hooks/Mapper/components/ui-kit';
import { CharacterTypeRaw, WithIsOwnCharacter } from '@/hooks/Mapper/types';
import { CharacterCard, LayoutEventBlocker, WdCheckbox } from '@/hooks/Mapper/components/ui-kit';
import { sortCharacters } from '@/hooks/Mapper/components/mapInterface/helpers/sortCharacters.ts'; 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 { useMapCheckPermissions, useMapGetOption } from '@/hooks/Mapper/mapRootProvider/hooks/api';
import { UserPermission } from '@/hooks/Mapper/types/permissions.ts'; import { UserPermission } from '@/hooks/Mapper/types/permissions.ts';
import useMaxWidth from '@/hooks/Mapper/hooks/useMaxWidth.ts'; import useMaxWidth from '@/hooks/Mapper/hooks/useMaxWidth.ts';
import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper'; import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper';
import { LocalCharactersList } from './components/LocalCharactersList';
type CharItemProps = { import { useLocalCharactersItemTemplate } from './hooks/useLocalCharacters';
compact: boolean; import { useLocalCharacterWidgetSettings } from './hooks/useLocalWidgetSettings';
} & 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,
};
export const LocalCharacters = () => { export const LocalCharacters = () => {
const { const {
data: { characters, userCharacters, selectedSystems, presentCharacters }, data: { characters, userCharacters, selectedSystems },
} = useMapRootState(); } = useMapRootState();
const [settings, setSettings] = useLocalStorageState<WindowLocalSettingsType>('window:local:settings', { const [settings, setSettings] = useLocalCharacterWidgetSettings();
defaultValue: STORED_DEFAULT_VALUES,
});
const [systemId] = selectedSystems; const [systemId] = selectedSystems;
const restrictOfflineShowing = useMapGetOption('restrict_offline_showing'); const restrictOfflineShowing = useMapGetOption('restrict_offline_showing');
const isAdminOrManager = useMapCheckPermissions([UserPermission.MANAGE_MAP]); const isAdminOrManager = useMapCheckPermissions([UserPermission.MANAGE_MAP]);
@@ -74,21 +28,31 @@ export const LocalCharacters = () => {
[isAdminOrManager, restrictOfflineShowing], [isAdminOrManager, restrictOfflineShowing],
); );
const itemTemplate = useItemTemplate();
const sorted = useMemo(() => { const sorted = useMemo(() => {
const sorted = characters const filtered = characters
.filter(x => x.location?.solar_system_id?.toString() === systemId) .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); .sort(sortCharacters);
if (!showOffline || !settings.showOffline) { if (!showOffline || !settings.showOffline) {
return sorted.filter(c => c.online); return filtered.filter(c => c.online);
} }
return sorted; return filtered;
// eslint-disable-next-line }, [
}, [showOffline, characters, settings.showOffline, settings.compact, systemId, userCharacters, presentCharacters]); characters,
systemId,
userCharacters,
settings.compact,
settings.showOffline,
settings.showShipName,
showOffline,
]);
const isNobodyHere = sorted.length === 0; const isNobodyHere = sorted.length === 0;
const isNotSelectedSystem = selectedSystems.length !== 1; const isNotSelectedSystem = selectedSystems.length !== 1;
@@ -97,6 +61,8 @@ export const LocalCharacters = () => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const compact = useMaxWidth(ref, 145); const compact = useMaxWidth(ref, 145);
const itemTemplate = useLocalCharactersItemTemplate(settings.showShipName);
return ( return (
<Widget <Widget
label={ label={
@@ -111,7 +77,20 @@ export const LocalCharacters = () => {
label={compact ? '' : 'Show offline'} label={compact ? '' : 'Show offline'}
value={settings.showOffline} value={settings.showOffline}
classNameLabel="text-stone-400 hover:text-stone-200 transition duration-300" classNameLabel="text-stone-400 hover:text-stone-200 transition duration-300"
onChange={() => setSettings(() => ({ ...settings, showOffline: !settings.showOffline }))} onChange={() => setSettings(prev => ({ ...prev, showOffline: !prev.showOffline }))}
/>
</WdTooltipWrapper>
)}
{settings.compact && (
<WdTooltipWrapper content="Show ship name in compact rows">
<WdCheckbox
size="xs"
labelSide="left"
label={compact ? '' : 'Show ship name'}
value={settings.showShipName}
classNameLabel="text-stone-400 hover:text-stone-200 transition duration-300"
onChange={() => setSettings(prev => ({ ...prev, showShipName: !prev.showShipName }))}
/> />
</WdTooltipWrapper> </WdTooltipWrapper>
)} )}
@@ -121,8 +100,8 @@ export const LocalCharacters = () => {
['hero-bars-2']: settings.compact, ['hero-bars-2']: settings.compact,
['hero-bars-3']: !settings.compact, ['hero-bars-3']: !settings.compact,
})} })}
onClick={() => setSettings(() => ({ ...settings, compact: !settings.compact }))} onClick={() => setSettings(prev => ({ ...prev, compact: !prev.compact }))}
></span> />
</LayoutEventBlocker> </LayoutEventBlocker>
</div> </div>
} }
@@ -140,15 +119,11 @@ export const LocalCharacters = () => {
)} )}
{showList && ( {showList && (
<VirtualScroller <LocalCharactersList
items={sorted} items={sorted}
itemSize={settings.compact ? 26 : 41} itemSize={settings.compact ? 26 : 41}
itemTemplate={itemTemplate} itemTemplate={itemTemplate}
className={clsx( containerClassName="w-full h-full overflow-x-hidden overflow-y-auto"
classes.VirtualScroller,
'w-full h-full overflow-x-hidden overflow-y-auto custom-scrollbar select-none',
)}
autoSize={false}
/> />
)} )}
</Widget> </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

@@ -9,7 +9,7 @@ export interface KillsWidgetSettings {
} }
export const DEFAULT_KILLS_WIDGET_SETTINGS: KillsWidgetSettings = { export const DEFAULT_KILLS_WIDGET_SETTINGS: KillsWidgetSettings = {
compact: false, compact: true,
showAll: false, showAll: false,
excludedSystems: [], excludedSystems: [],
version: 0, version: 0,

View File

@@ -28,6 +28,7 @@
.CharIcon { .CharIcon {
border-radius: 0 !important; border-radius: 0 !important;
border: 1px solid #2b2b2b;
} }
.CharRow { .CharRow {

View File

@@ -8,8 +8,8 @@ import { emitMapEvent } from '@/hooks/Mapper/events';
type CharacterCardProps = { type CharacterCardProps = {
compact?: boolean; compact?: boolean;
showShipName?: boolean;
showSystem?: boolean; showSystem?: boolean;
showShipName?: boolean;
useSystemsCache?: boolean; useSystemsCache?: boolean;
} & CharacterTypeRaw & } & CharacterTypeRaw &
WithIsOwnCharacter; WithIsOwnCharacter;
@@ -22,8 +22,15 @@ export const getShipName = (name: string) => {
.replace(/\\x([\dA-Fa-f]{2})/g, (_, grp) => String.fromCharCode(parseInt(grp, 16))); .replace(/\\x([\dA-Fa-f]{2})/g, (_, grp) => String.fromCharCode(parseInt(grp, 16)));
}; };
// A small divider between fields:
const Divider = () => (
<span className="mx-1 text-gray-400" aria-hidden="true">
|
</span>
);
export const CharacterCard = ({ export const CharacterCard = ({
compact, compact = false,
isOwn, isOwn,
showSystem, showSystem,
showShipName, showShipName,
@@ -37,59 +44,135 @@ export const CharacterCard = ({
}); });
}, [char]); }, [char]);
// Precompute the ship name (decoded):
const shipNameText = char.ship?.ship_name ? getShipName(char.ship.ship_name) : '';
// -----------------------------------------------------------------------------
// COMPACT MODE: Main line =
// if (showShipName & haveShipName) => name | shipName (skip ticker)
// else => name | [ticker]
// -----------------------------------------------------------------------------
const compactLine = (
<>
{/* Character Name (lighter shade) */}
<span className="text-gray-200">{char.name}</span>
<Divider />
{showShipName && shipNameText ? (
// Show the ship name in place of the ticker (use indigo color to match corp/alliance)
<span className="text-indigo-300">{shipNameText}</span>
) : (
// Show the [ticker] (indigo)
<span className="text-indigo-300">[{char.alliance_id ? char.alliance_ticker : char.corporation_ticker}]</span>
)}
</>
);
// -----------------------------------------------------------------------------
// NON-COMPACT MODE:
// Line 1 => name | [ticker]
// Line 2 => (shipName) always, if it exists
// -----------------------------------------------------------------------------
const nonCompactLine1 = (
<div className="overflow-hidden text-ellipsis whitespace-nowrap">
{/* Character Name (lighter shade) */}
<span className="text-gray-200">{char.name}</span>
<Divider />
<span className="text-indigo-300">[{char.alliance_id ? char.alliance_ticker : char.corporation_ticker}]</span>
</div>
);
const nonCompactLine2 = (
<>
{shipNameText && (
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-gray-300">{shipNameText}</div>
)}
</>
);
return ( return (
<div className={clsx(classes.CharacterCard, 'w-full text-xs', 'flex flex-col box-border')} onClick={handleSelect}> <div className={clsx(classes.CharacterCard, 'w-full text-xs box-border')} onClick={handleSelect}>
<div className="flex px-2 py-1 gap-1"> <div
{!compact && ( className={clsx(
'w-full px-2 py-1 overflow-hidden gap-1',
compact ? 'grid items-center' : 'flex flex-col md:flex-row items-start',
)}
style={compact ? { gridTemplateColumns: 'auto 1fr auto', minWidth: 0 } : undefined}
>
{compact ? (
<img
src={`https://images.evetech.net/characters/${char.eve_id}/portrait`}
alt={`${char.name} portrait`}
style={{
width: '18px',
height: '18px',
// Remove circle shape for a square image:
borderRadius: 0,
marginRight: '4px',
flexShrink: 0,
// Slightly lighter than typical dark background:
border: '1px solid #2b2b2b',
}}
/>
) : (
<span <span
className={clsx(classes.EveIcon, classes.CharIcon, 'wd-bg-default')} className={clsx(classes.EveIcon, classes.CharIcon, 'wd-bg-default')}
style={{ style={{
// The SCSS below ensures the image is square with a border.
backgroundImage: `url(https://images.evetech.net/characters/${char.eve_id}/portrait)`, backgroundImage: `url(https://images.evetech.net/characters/${char.eve_id}/portrait)`,
}} }}
/> />
)} )}
<div className="flex flex-col flex-grow">
{/*
Middle section:
- In compact mode, everything is on one line (Name + possibly ShipName or ticker).
- In non-compact mode, line 1 has (Name | Ticker), line 2 has shipName if it exists.
*/}
<div
className={clsx('overflow-hidden text-ellipsis', {
'text-left px-1': compact,
'flex-grow': !compact,
})}
style={{ minWidth: 0 }}
>
{/* This left border highlights "isOwn" in the same way as older code. */}
<div <div
className={clsx(classes.CharRow, 'w-full', { className={clsx('overflow-hidden whitespace-nowrap', {
[classes.TwoColumns]: !char.ship, [classes.CardBorderLeftIsOwn]: isOwn,
[classes.ThreeColumns]: char.ship,
})} })}
> >
<span {compact ? compactLine : nonCompactLine1}
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> </div>
{/* Non-compact second line always shows shipName if available */}
{showShipName && !compact && char.ship?.ship_name && ( {!compact && nonCompactLine2}
<div className="grid w-full">
<span className="text-ellipsis overflow-hidden whitespace-nowrap">
{getShipName(char.ship.ship_name)}
</span>
</div>
)}
{showSystem && !compact && char.location?.solar_system_id && (
<SystemView systemId={char.location.solar_system_id.toString()} useSystemsCache={useSystemsCache} />
)}
</div> </div>
{/*
Right column for Ship Type (compact) or "pushed" to the right (non-compact).
Ship Type remains text-yellow-400.
*/}
{char.ship?.ship_type_info?.name && (
<div
className={clsx('text-yellow-400 text-ellipsis overflow-hidden whitespace-nowrap', {
'text-right px-1 flex-shrink-0': compact,
'mt-1 md:mt-0 ml-auto': !compact,
})}
style={{ maxWidth: compact ? '120px' : '200px' }}
title={char.ship.ship_type_info.name}
>
{char.ship.ship_type_info.name}
</div>
)}
</div> </div>
{/*
System row at the bottom if `showSystem && system exists`.
*/}
{showSystem && char.location?.solar_system_id && (
<div className="px-2 pb-1">
<SystemView systemId={char.location.solar_system_id.toString()} useSystemsCache={useSystemsCache} />
</div>
)}
</div> </div>
); );
}; };

View File

@@ -12,7 +12,7 @@ export enum TooltipPosition {
bottom = 'bottom', bottom = 'bottom',
} }
export interface TooltipProps { export interface TooltipProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'content'> {
position?: TooltipPosition; position?: TooltipPosition;
offset?: number; offset?: number;
content: (() => React.ReactNode) | React.ReactNode; content: (() => React.ReactNode) | React.ReactNode;
@@ -27,183 +27,253 @@ export interface OffsetPosition {
export interface WdTooltipHandlers { export interface WdTooltipHandlers {
show: (e?: React.MouseEvent) => void; show: (e?: React.MouseEvent) => void;
hide: (e?: React.MouseEvent) => void; hide: () => void;
getIsMouseInside: () => boolean; getIsMouseInside: () => boolean;
} }
export const WdTooltip = forwardRef( const LEAVE_DELAY = 100;
(props: TooltipProps & { className?: string }, ref: ForwardedRef<WdTooltipHandlers>) => {
const {
content,
targetSelector,
position: tPosition = TooltipPosition.default,
className,
offset = 5,
interactive = false,
} = props;
const [visible, setVisible] = useState(false); export const WdTooltip = forwardRef(function WdTooltip(
const [pos, setPos] = useState<OffsetPosition | null>(null); {
const [ev, setEv] = useState<React.MouseEvent>(); content,
const tooltipRef = useRef<HTMLDivElement>(null); targetSelector,
const [isMouseInsideTooltip, setIsMouseInsideTooltip] = useState(false); position: tPosition = TooltipPosition.default,
offset = 5,
interactive = false,
className,
...restProps
}: TooltipProps,
ref: ForwardedRef<WdTooltipHandlers>,
) {
const [visible, setVisible] = useState(false);
const [pos, setPos] = useState<OffsetPosition | null>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const calcTooltipPosition = useCallback(({ x, y }: { x: number; y: number }) => { const [isMouseInsideTooltip, setIsMouseInsideTooltip] = useState(false);
if (!tooltipRef.current) return { left: x, top: y };
const tooltipWidth = tooltipRef.current.offsetWidth;
const tooltipHeight = tooltipRef.current.offsetHeight;
let newLeft = x;
let newTop = y;
if (newLeft < 0) newLeft = 10; const [reactEvt, setReactEvt] = useState<React.MouseEvent>();
if (newTop < 0) newTop = 10;
if (newLeft + tooltipWidth + 10 > window.innerWidth) {
newLeft = window.innerWidth - tooltipWidth - 10;
}
if (newTop + tooltipHeight + 10 > window.innerHeight) {
newTop = window.innerHeight - tooltipHeight - 10;
}
return { left: newLeft, top: newTop };
}, []);
useImperativeHandle(ref, () => ({ const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
show: (mouseEvt?: React.MouseEvent) => {
if (mouseEvt) setEv(mouseEvt); const calcTooltipPosition = useCallback(({ x, y }: { x: number; y: number }) => {
setPos(null); if (!tooltipRef.current) return { left: x, top: y };
setVisible(true);
}, const tooltipWidth = tooltipRef.current.offsetWidth;
hide: () => { const tooltipHeight = tooltipRef.current.offsetHeight;
let newLeft = x;
let newTop = y;
if (newLeft < 0) newLeft = 10;
if (newTop < 0) newTop = 10;
const rightEdge = newLeft + tooltipWidth + 10;
if (rightEdge > window.innerWidth) {
newLeft = window.innerWidth - tooltipWidth - 10;
}
const bottomEdge = newTop + tooltipHeight + 10;
if (bottomEdge > window.innerHeight) {
newTop = window.innerHeight - tooltipHeight - 10;
}
return { left: newLeft, top: newTop };
}, []);
const scheduleHide = useCallback(() => {
if (!interactive) {
setVisible(false);
return;
}
if (!hideTimeoutRef.current) {
hideTimeoutRef.current = setTimeout(() => {
setVisible(false); setVisible(false);
}, }, LEAVE_DELAY);
getIsMouseInside: () => isMouseInsideTooltip, }
})); }, [interactive]);
useEffect(() => { useImperativeHandle(ref, () => ({
if (!tooltipRef.current || !ev) return; show: (e?: React.MouseEvent) => {
const tooltipEl = tooltipRef.current; if (hideTimeoutRef.current) {
const { clientX, clientY, target } = ev; clearTimeout(hideTimeoutRef.current);
const targetBounds = (target as HTMLElement).getBoundingClientRect(); hideTimeoutRef.current = null;
}
if (e && tooltipRef.current) {
const { clientX, clientY } = e;
setPos(calcTooltipPosition({ x: clientX, y: clientY }));
setReactEvt(e);
}
setVisible(true);
},
hide: () => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
setVisible(false);
},
getIsMouseInside: () => isMouseInsideTooltip,
}));
let offsetX = clientX; useEffect(() => {
let offsetY = clientY; if (!tooltipRef.current || !reactEvt) return;
if (tPosition === TooltipPosition.left) { const { clientX, clientY, target } = reactEvt;
const tooltipBounds = tooltipEl.getBoundingClientRect(); const tooltipEl = tooltipRef.current;
offsetX = targetBounds.left - tooltipBounds.width - offset; const triggerEl = target as HTMLElement;
offsetY = targetBounds.y + targetBounds.height / 2 - tooltipBounds.height / 2; const triggerBounds = triggerEl.getBoundingClientRect();
if (offsetX <= 0) {
offsetX = targetBounds.left + targetBounds.width + offset; let x = clientX;
} let y = clientY;
setPos(calcTooltipPosition({ x: offsetX, y: offsetY }));
if (tPosition === TooltipPosition.left) {
const tooltipBounds = tooltipEl.getBoundingClientRect();
x = triggerBounds.left - tooltipBounds.width - offset;
y = triggerBounds.y + triggerBounds.height / 2 - tooltipBounds.height / 2;
if (x <= 0) {
x = triggerBounds.left + triggerBounds.width + offset;
}
setPos(calcTooltipPosition({ x, y }));
return;
}
if (tPosition === TooltipPosition.right) {
x = triggerBounds.left + triggerBounds.width + offset;
y = triggerBounds.y + triggerBounds.height / 2 - tooltipEl.offsetHeight / 2;
setPos(calcTooltipPosition({ x, y }));
return;
}
if (tPosition === TooltipPosition.top) {
x = triggerBounds.x + triggerBounds.width / 2 - tooltipEl.offsetWidth / 2;
y = triggerBounds.top - tooltipEl.offsetHeight - offset;
setPos(calcTooltipPosition({ x, y }));
return;
}
if (tPosition === TooltipPosition.bottom) {
x = triggerBounds.x + triggerBounds.width / 2 - tooltipEl.offsetWidth / 2;
y = triggerBounds.bottom + offset;
setPos(calcTooltipPosition({ x, y }));
return;
}
setPos(calcTooltipPosition({ x, y }));
}, [calcTooltipPosition, reactEvt, tPosition, offset]);
useEffect(() => {
if (!targetSelector) return;
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; return;
} }
if (tPosition === TooltipPosition.right) { if (hideTimeoutRef.current) {
offsetX = targetBounds.left + targetBounds.width + offset; clearTimeout(hideTimeoutRef.current);
offsetY = targetBounds.y + targetBounds.height / 2 - tooltipEl.offsetHeight / 2; hideTimeoutRef.current = null;
setPos(calcTooltipPosition({ x: offsetX, y: offsetY }));
return;
} }
setVisible(true);
if (tPosition === TooltipPosition.top) { if (triggerEl && tooltipRef.current) {
offsetY = targetBounds.top - tooltipEl.offsetHeight - offset; const rect = triggerEl.getBoundingClientRect();
offsetX = targetBounds.x + targetBounds.width / 2 - tooltipEl.offsetWidth / 2; const tooltipEl = tooltipRef.current;
setPos(calcTooltipPosition({ x: offsetX, y: offsetY }));
return;
}
if (tPosition === TooltipPosition.bottom) { let x = evt.clientX;
offsetY = targetBounds.bottom + offset; let y = evt.clientY;
offsetX = targetBounds.x + targetBounds.width / 2 - tooltipEl.offsetWidth / 2;
setPos(calcTooltipPosition({ x: offsetX, y: offsetY }));
return;
}
setPos(calcTooltipPosition({ x: offsetX, y: offsetY })); switch (tPosition) {
}, [calcTooltipPosition, ev, tPosition, offset]); case TooltipPosition.left: {
useEffect(() => {
if (!targetSelector) return;
function handleMouseMove(nativeEvt: globalThis.MouseEvent) {
const targetEl = nativeEvt.target as HTMLElement | null;
if (!targetEl) {
setVisible(false);
return;
}
const triggerEl = targetEl.closest(targetSelector!);
const isInsideTooltip = interactive && tooltipRef.current?.contains(targetEl);
if (!triggerEl && !isInsideTooltip) {
setVisible(false);
return;
}
setVisible(true);
if (triggerEl && tooltipRef.current) {
const rect = triggerEl.getBoundingClientRect();
const tooltipEl = tooltipRef.current;
let x = nativeEvt.clientX;
let y = nativeEvt.clientY;
if (tPosition === TooltipPosition.left) {
x = rect.left - tooltipEl.offsetWidth - offset; x = rect.left - tooltipEl.offsetWidth - offset;
y = rect.y + rect.height / 2 - tooltipEl.offsetHeight / 2; y = rect.y + rect.height / 2 - tooltipEl.offsetHeight / 2;
if (x <= 0) { if (x <= 0) {
x = rect.left + rect.width + offset; x = rect.left + rect.width + offset;
} }
} else if (tPosition === TooltipPosition.right) { break;
}
case TooltipPosition.right: {
x = rect.left + rect.width + offset; x = rect.left + rect.width + offset;
y = rect.y + rect.height / 2 - tooltipEl.offsetHeight / 2; y = rect.y + rect.height / 2 - tooltipEl.offsetHeight / 2;
} else if (tPosition === TooltipPosition.top) { break;
}
case TooltipPosition.top: {
x = rect.x + rect.width / 2 - tooltipEl.offsetWidth / 2; x = rect.x + rect.width / 2 - tooltipEl.offsetWidth / 2;
y = rect.top - tooltipEl.offsetHeight - offset; y = rect.top - tooltipEl.offsetHeight - offset;
} else if (tPosition === TooltipPosition.bottom) { break;
}
case TooltipPosition.bottom: {
x = rect.x + rect.width / 2 - tooltipEl.offsetWidth / 2; x = rect.x + rect.width / 2 - tooltipEl.offsetWidth / 2;
y = rect.bottom + offset; y = rect.bottom + offset;
break;
} }
default:
setPos(calcTooltipPosition({ x, y }));
} }
setPos(calcTooltipPosition({ x, y }));
} }
};
const debounced = debounce(handleMouseMove, 10); const debounced = debounce(handleMouseMove, 15);
const listener = (evt: Event) => {
debounced(evt as MouseEvent);
};
const listener: EventListener = evt => { document.addEventListener('mousemove', listener);
debounced(evt as globalThis.MouseEvent); return () => {
}; document.removeEventListener('mousemove', listener);
debounced.cancel();
};
}, [targetSelector, interactive, tPosition, offset, calcTooltipPosition, scheduleHide]);
document.addEventListener('mousemove', listener); useEffect(() => {
return () => { return () => {
document.removeEventListener('mousemove', listener); if (hideTimeoutRef.current) {
}; clearTimeout(hideTimeoutRef.current);
}, [targetSelector, interactive, tPosition, offset, calcTooltipPosition]); }
};
}, []);
return createPortal( if (!visible) return null;
visible && (
<div return createPortal(
ref={tooltipRef} <div
className={clsx( ref={tooltipRef}
classes.tooltip, className={clsx(
interactive ? 'pointer-events-auto' : 'pointer-events-none', classes.tooltip,
'absolute p-1 border rounded-sm border-green-300 border-opacity-10 bg-stone-900 bg-opacity-90', interactive ? 'pointer-events-auto' : 'pointer-events-none',
pos === null ? 'invisible' : '', 'absolute p-1 border rounded-sm border-green-300 border-opacity-10 bg-stone-900 bg-opacity-90',
className, pos === null ? 'invisible' : '',
)} className,
style={{ )}
top: pos?.top ?? 0, style={{
left: pos?.left ?? 0, top: pos?.top ?? 0,
zIndex: 10000, left: pos?.left ?? 0,
}} zIndex: 10000,
onMouseEnter={() => interactive && setIsMouseInsideTooltip(true)} }}
onMouseLeave={() => interactive && setIsMouseInsideTooltip(false)} onMouseEnter={() => {
> if (interactive && hideTimeoutRef.current) {
{typeof content === 'function' ? content() : content} clearTimeout(hideTimeoutRef.current);
</div> hideTimeoutRef.current = null;
), }
document.body, setIsMouseInsideTooltip(true);
); }}
}, onMouseLeave={() => {
); setIsMouseInsideTooltip(false);
if (interactive) {
scheduleHide();
}
}}
{...restProps}
>
{typeof content === 'function' ? content() : content}
</div>,
document.body,
);
});
WdTooltip.displayName = 'WdTooltip'; WdTooltip.displayName = 'WdTooltip';

View File

@@ -1,3 +1,25 @@
/* WdTooltipWrapper.module.scss */
.WdTooltipWrapperRoot { .WdTooltipWrapperRoot {
display: inline-block; 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

@@ -82,5 +82,6 @@
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC" "license": "ISC",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }

View File

@@ -1346,9 +1346,9 @@ camelcase-css@^2.0.1:
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
caniuse-lite@^1.0.30001587, caniuse-lite@^1.0.30001599: caniuse-lite@^1.0.30001587, caniuse-lite@^1.0.30001599:
version "1.0.30001600" version "1.0.30001696"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001600.tgz" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001696.tgz"
integrity sha512-+2S9/2JFhYmYaDpZvo0lKkfvuKIglrx68MwOBqMGHhQsNkLjB5xtc/TGoEPs+MxjSyN/72qer2g97nzR641mOQ== integrity sha512-pDCPkvzfa39ehJtJ+OwGT/2yvT2SbjfHhiIW2LWOAcMQ7BzwxT/XuyUp4OTOd0XFWA6BKw0JalnBHgSi5DGJBQ==
chalk@^2.4.2: chalk@^2.4.2:
version "2.4.2" version "2.4.2"

View File

@@ -71,7 +71,7 @@ defmodule WandererApp.Character.TransactionsTracker do
@impl true @impl true
def handle_info(:shutdown, %Impl{} = state) do 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} {:stop, :normal, state}
end end

View File

@@ -118,7 +118,7 @@ defmodule WandererApp.Map.ZkbDataFetcher do
|> Enum.map(&elem(&1, 0)) |> Enum.map(&elem(&1, 0))
if changed_systems == [] do if changed_systems == [] do
Logger.debug("[ZkbDataFetcher] No changes in detailed kills for map_id=#{map_id}") Logger.debug(fn -> "[ZkbDataFetcher] No changes in detailed kills for map_id=#{map_id}" end)
:ok :ok
else else
# Build new details for each changed system # Build new details for each changed system
@@ -200,7 +200,7 @@ defmodule WandererApp.Map.ZkbDataFetcher do
if WandererApp.Cache.lookup!("map_#{map_id}:started", false) do if WandererApp.Cache.lookup!("map_#{map_id}:started", false) do
fun.() fun.()
else else
Logger.debug("[ZkbDataFetcher] Map #{map_id} not started => skipping #{label}") Logger.debug(fn -> "[ZkbDataFetcher] Map #{map_id} not started => skipping #{label}" end)
:ok :ok
end end
end end

View File

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

View File

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

View File

@@ -71,12 +71,10 @@ defmodule WandererApp.Zkb.KillsPreloader do
system_tuples = gather_visible_systems(active_maps_with_subscription) system_tuples = gather_visible_systems(active_maps_with_subscription)
unique_systems = Enum.uniq(system_tuples) unique_systems = Enum.uniq(system_tuples)
Logger.debug(fn -> Logger.debug(fn -> "
""" [KillsPreloader] Found #{length(unique_systems)} unique systems \
[KillsPreloader] Found #{length(unique_systems)} unique systems \ across #{length(last_active_maps)} map(s)
across #{length(active_maps_with_subscription)} map(s) " end)
"""
end)
# ---- QUICK PASS ---- # ---- QUICK PASS ----
state_quick = %{state | phase: :quick_pass} state_quick = %{state | phase: :quick_pass}
@@ -185,9 +183,7 @@ defmodule WandererApp.Zkb.KillsPreloader do
end end
defp fetch_kills_for_system(system_id, :quick, hours, limit, state) do defp fetch_kills_for_system(system_id, :quick, hours, limit, state) do
Logger.debug(fn -> Logger.debug(fn -> "[KillsPreloader] Quick fetch => system=#{system_id}, hours=#{hours}, limit=#{limit}" end)
"[KillsPreloader] Quick fetch => system=#{system_id}, hours=#{hours}, limit=#{limit}"
end)
case KillsProvider.Fetcher.fetch_kills_for_system(system_id, hours, state, case KillsProvider.Fetcher.fetch_kills_for_system(system_id, hours, state,
limit: limit, limit: limit,
@@ -206,9 +202,7 @@ defmodule WandererApp.Zkb.KillsPreloader do
end end
defp fetch_kills_for_system(system_id, :expanded, hours, limit, state) do defp fetch_kills_for_system(system_id, :expanded, hours, limit, state) do
Logger.debug(fn -> Logger.debug(fn -> "[KillsPreloader] Expanded fetch => system=#{system_id}, hours=#{hours}, limit=#{limit} (forcing refresh)" end)
"[KillsPreloader] Expanded fetch => system=#{system_id}, hours=#{hours}, limit=#{limit} (forcing refresh)"
end)
with {:ok, kills_1h, updated_state} <- with {:ok, kills_1h, updated_state} <-
KillsProvider.Fetcher.fetch_kills_for_system(system_id, hours, state, KillsProvider.Fetcher.fetch_kills_for_system(system_id, hours, state,
@@ -232,10 +226,7 @@ defmodule WandererApp.Zkb.KillsPreloader do
defp maybe_fetch_more_if_needed(system_id, kills_1h, limit, state) do defp maybe_fetch_more_if_needed(system_id, kills_1h, limit, state) do
if length(kills_1h) < limit do if length(kills_1h) < limit do
needed = limit - length(kills_1h) needed = limit - length(kills_1h)
Logger.debug(fn -> "[KillsPreloader] Expanding to #{@expanded_hours}h => system=#{system_id}, need=#{needed} more kills" end)
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, case KillsProvider.Fetcher.fetch_kills_for_system(system_id, @expanded_hours, state,
limit: needed, limit: needed,

View File

@@ -20,7 +20,7 @@ defmodule WandererApp.Zkb.KillsProvider.KillsCache do
Store the killmail data, keyed by killmail_id, with a 24h TTL. Store the killmail data, keyed by killmail_id, with a 24h TTL.
""" """
def put_killmail(killmail_id, kill_data) do def put_killmail(killmail_id, kill_data) do
Logger.debug("[KillsCache] Storing killmail => killmail_id=#{killmail_id}") Logger.debug(fn -> "[KillsCache] Storing killmail => killmail_id=#{killmail_id}" end)
Cache.put(killmail_key(killmail_id), kill_data, ttl: @killmail_ttl) Cache.put(killmail_key(killmail_id), kill_data, ttl: @killmail_ttl)
end end
@@ -30,8 +30,7 @@ defmodule WandererApp.Zkb.KillsProvider.KillsCache do
""" """
def fetch_cached_kills(system_id) do def fetch_cached_kills(system_id) do
killmail_ids = get_system_killmail_ids(system_id) killmail_ids = get_system_killmail_ids(system_id)
# Debug-level log for performance checks Logger.debug(fn -> "[KillsCache] fetch_cached_kills => system_id=#{system_id}, count=#{length(killmail_ids)}" end)
Logger.debug("[KillsCache] fetch_cached_kills => system_id=#{system_id}, count=#{length(killmail_ids)}")
killmail_ids killmail_ids
|> Enum.map(&get_killmail/1) |> Enum.map(&get_killmail/1)
@@ -130,7 +129,7 @@ defmodule WandererApp.Zkb.KillsProvider.KillsCache do
final_expiry_ms = max(@base_full_fetch_expiry_ms + offset, 60_000) final_expiry_ms = max(@base_full_fetch_expiry_ms + offset, 60_000)
expires_at_ms = now_ms + final_expiry_ms expires_at_ms = now_ms + final_expiry_ms
Logger.debug("[KillsCache] Marking system=#{system_id} recently_fetched? until #{expires_at_ms} (ms)") Logger.debug(fn -> "[KillsCache] Marking system=#{system_id} recently_fetched? until #{expires_at_ms} (ms)" end)
Cache.put(fetched_timestamp_key(system_id), expires_at_ms) Cache.put(fetched_timestamp_key(system_id), expires_at_ms)
end end

View File

@@ -23,12 +23,12 @@ defmodule WandererApp.Zkb.KillsProvider.Fetcher do
{Map.put(acc_map, sid, kills), new_st} {Map.put(acc_map, sid, kills), new_st}
{:error, reason, new_st} -> {:error, reason, new_st} ->
Logger.debug("[Fetcher] system=#{sid} => error=#{inspect(reason)}") Logger.debug(fn -> "[Fetcher] system=#{sid} => error=#{inspect(reason)}" end)
{Map.put(acc_map, sid, {:error, reason}), new_st} {Map.put(acc_map, sid, {:error, reason}), new_st}
end end
end) end)
Logger.debug("[Fetcher] fetch_kills_for_systems => done, final_map_size=#{map_size(final_map)} calls=#{final_state.calls_count}") Logger.debug(fn -> "[Fetcher] fetch_kills_for_systems => done, final_map_size=#{map_size(final_map)} calls=#{final_state.calls_count}" end)
{:ok, final_map} {:ok, final_map}
rescue rescue
e -> e ->
@@ -57,10 +57,10 @@ defmodule WandererApp.Zkb.KillsProvider.Fetcher do
if not force? and KillsCache.recently_fetched?(system_id) do if not force? and KillsCache.recently_fetched?(system_id) do
cached_kills = KillsCache.fetch_cached_kills(system_id) cached_kills = KillsCache.fetch_cached_kills(system_id)
final = maybe_take(cached_kills, limit) final = maybe_take(cached_kills, limit)
Logger.debug("#{log_prefix}, recently_fetched?=true => returning #{length(final)} cached kills") Logger.debug(fn -> "#{log_prefix}, recently_fetched?=true => returning #{length(final)} cached kills" end)
{:ok, final, state} {:ok, final, state}
else else
Logger.debug("#{log_prefix}, hours=#{since_hours}, limit=#{inspect(limit)}, force=#{force?}") Logger.debug(fn -> "#{log_prefix}, hours=#{since_hours}, limit=#{inspect(limit)}, force=#{force?}" end)
cutoff_dt = hours_ago(since_hours) cutoff_dt = hours_ago(since_hours)
@@ -75,9 +75,9 @@ defmodule WandererApp.Zkb.KillsProvider.Fetcher do
KillsCache.put_full_fetched_timestamp(system_id) KillsCache.put_full_fetched_timestamp(system_id)
final_kills = KillsCache.fetch_cached_kills(system_id) |> maybe_take(limit) final_kills = KillsCache.fetch_cached_kills(system_id) |> maybe_take(limit)
Logger.debug( Logger.debug(fn ->
"#{log_prefix}, total_fetched=#{total_fetched}, final_cached=#{length(final_kills)}, calls_count=#{new_st.calls_count}" "#{log_prefix}, total_fetched=#{total_fetched}, final_cached=#{length(final_kills)}, calls_count=#{new_st.calls_count}"
) end)
{:ok, final_kills, new_st} {:ok, final_kills, new_st}
@@ -117,7 +117,7 @@ defmodule WandererApp.Zkb.KillsProvider.Fetcher do
with {:ok, st1} <- increment_calls_count(state), with {:ok, st1} <- increment_calls_count(state),
{:ok, st2, partials} <- ZkbApi.fetch_and_parse_page(system_id, page, st1) do {:ok, st2, partials} <- ZkbApi.fetch_and_parse_page(system_id, page, st1) do
Logger.debug("[Fetcher] system=#{system_id}, page=#{page}, partials_count=#{length(partials)}") Logger.debug(fn -> "[Fetcher] system=#{system_id}, page=#{page}, partials_count=#{length(partials)}" end)
{_count_stored, older_found?, total_now} = {_count_stored, older_found?, total_now} =
Enum.reduce_while(partials, {0, false, total_so_far}, fn partial, {acc_count, had_older, acc_total} -> Enum.reduce_while(partials, {0, false, total_so_far}, fn partial, {acc_count, had_older, acc_total} ->

View File

@@ -19,7 +19,7 @@ defmodule WandererApp.Zkb.KillsProvider.Websocket do
# Called by `KillsProvider.handle_in` # Called by `KillsProvider.handle_in`
def handle_in({:text, frame}, state) do def handle_in({:text, frame}, state) do
Logger.debug("[KillsProvider.Websocket] Received frame => #{frame}") Logger.debug(fn -> "[KillsProvider.Websocket] Received frame => #{frame}" end)
partial = Jason.decode!(frame) partial = Jason.decode!(frame)
parse_and_store_zkb_partial(partial) parse_and_store_zkb_partial(partial)
{:ok, state} {:ok, state}
@@ -61,14 +61,14 @@ defmodule WandererApp.Zkb.KillsProvider.Websocket do
end end
defp handle_subscribe(channel, state) do defp handle_subscribe(channel, state) do
Logger.debug("[KillsProvider.Websocket] Subscribing to #{channel}") Logger.debug(fn -> "[KillsProvider.Websocket] Subscribing to #{channel}" end)
payload = Jason.encode!(%{"action" => "sub", "channel" => channel}) payload = Jason.encode!(%{"action" => "sub", "channel" => channel})
{:reply, {:text, payload}, state} {:reply, {:text, payload}, state}
end end
# The partial from zKillboard has killmail_id + zkb.hash, but no time/victim/attackers # The partial from zKillboard has killmail_id + zkb.hash, but no time/victim/attackers
defp parse_and_store_zkb_partial(%{"killmail_id" => kill_id, "zkb" => %{"hash" => kill_hash}} = partial) do defp parse_and_store_zkb_partial(%{"killmail_id" => kill_id, "zkb" => %{"hash" => kill_hash}} = partial) do
Logger.debug("[KillsProvider.Websocket] parse_and_store_zkb_partial => kill_id=#{kill_id}") Logger.debug(fn -> "[KillsProvider.Websocket] parse_and_store_zkb_partial => kill_id=#{kill_id}" end)
case Esi.get_killmail(kill_id, kill_hash) do case Esi.get_killmail(kill_id, kill_hash) do
{:ok, full_esi_data} -> {:ok, full_esi_data} ->
# Merge partial zKB fields (like totalValue) onto ESI data # Merge partial zKB fields (like totalValue) onto ESI data