Compare commits

...

32 Commits

Author SHA1 Message Date
Aleksei Chichenkov
294e718b4f Merge pull request #583 from fbnyes/main
MR into separate branch
2026-01-26 10:05:53 +03:00
CI
89d7df0ba2 chore: [skip ci] 2026-01-14 22:29:39 +00:00
CI
ba0c10d2e4 chore: release version v1.92.0 2026-01-14 22:29:39 +00:00
Dmitry Popov
996c88d839 Merge pull request #575 from wanderer-industries/k162-selector
K162 selector
2026-01-15 02:29:09 +04:00
Dmitry Popov
80e998cf79 fix(core): Show c1/c2/c3 or c4/c5 or link signature modal 2026-01-14 23:28:47 +01:00
fbn
527c927bb8 Merge branch 'wanderer-industries:main' into main 2026-01-14 21:12:31 +01:00
Fabian Wechselberger
fd04f64634 feat: Edge coloring, layout settings, layout orientation switch 2026-01-13 21:01:23 +01:00
Dmitry Popov
d2bcb89fa1 Merge branch 'main' into k162-selector 2026-01-13 20:27:48 +01:00
CI
922f296f17 chore: [skip ci] 2026-01-13 00:16:39 +00:00
CI
71dc20c933 chore: release version v1.91.11 2026-01-13 00:16:39 +00:00
Dmitry Popov
80f7d34d3d Merge pull request #573 from guarzo/guarzo/maprelayreturn
fix: allow sig api when map relay is off
2026-01-13 04:16:06 +04:00
Guarzo
113fe1c695 fix: allow sig api when map relay is off 2026-01-12 23:59:20 +00:00
Fabian Wechselberger
34ae21b7c3 Auto layout when new connection. 2026-01-12 23:58:58 +01:00
Fabian Wechselberger
797cda2577 Auto Layout with multi root support. 2026-01-12 23:26:01 +01:00
DanSylvest
5550844912 feat: Added ability to select a range of wh classes for k162. 2026-01-12 12:39:53 +03:00
CI
0228e68a1d chore: [skip ci] 2026-01-07 12:35:19 +00:00
CI
3424667af1 chore: release version v1.91.10 2026-01-07 12:35:19 +00:00
Dmitry Popov
6c7b28a6c1 Merge pull request #571 from guarzo/guarzo/sigapi2
fix: remove actor context requirement from sig api
2026-01-07 16:34:34 +04:00
Guarzo
3988079cd3 fix: remove actor context requirement from sig api 2026-01-07 04:24:15 +00:00
CI
f5d407fee0 chore: [skip ci] 2026-01-06 15:38:03 +00:00
CI
a857422c46 chore: release version v1.91.9 2026-01-06 15:38:02 +00:00
Dmitry Popov
ec6717d0ef Merge branch 'main' of github.com:wanderer-industries/wanderer 2026-01-06 16:37:32 +01:00
Dmitry Popov
56dacdcbbd fix(core): fixed rally point cancel logic 2026-01-06 16:37:29 +01:00
CI
c8e17b1691 chore: [skip ci] 2026-01-06 14:07:08 +00:00
CI
19c7fe59ee chore: release version v1.91.8 2026-01-06 14:07:08 +00:00
Dmitry Popov
682100c231 Merge branch 'main' of github.com:wanderer-industries/wanderer 2026-01-06 15:06:34 +01:00
Dmitry Popov
f9ac79cdcc fix(core): fixed rally point cancel logic 2026-01-06 15:06:31 +01:00
CI
f09f220645 chore: [skip ci] 2026-01-05 20:29:10 +00:00
CI
e585cdfd20 chore: release version v1.91.7 2026-01-05 20:29:10 +00:00
Dmitry Popov
3a3180f7b3 Merge branch 'main' of github.com:wanderer-industries/wanderer 2026-01-05 21:28:38 +01:00
Dmitry Popov
53abc580e5 chore: added promo on characters page 2026-01-05 21:28:35 +01:00
CI
8710d172a0 chore: [skip ci] 2026-01-04 23:49:15 +00:00
47 changed files with 15237 additions and 1103 deletions

View File

@@ -2,6 +2,60 @@
<!-- changelog -->
## [v1.92.0](https://github.com/wanderer-industries/wanderer/compare/v1.91.11...v1.92.0) (2026-01-14)
### Features:
* Added ability to select a range of wh classes for k162.
### Bug Fixes:
* core: Show c1/c2/c3 or c4/c5 or link signature modal
## [v1.91.11](https://github.com/wanderer-industries/wanderer/compare/v1.91.10...v1.91.11) (2026-01-13)
### Bug Fixes:
* allow sig api when map relay is off
## [v1.91.10](https://github.com/wanderer-industries/wanderer/compare/v1.91.9...v1.91.10) (2026-01-07)
### Bug Fixes:
* remove actor context requirement from sig api
## [v1.91.9](https://github.com/wanderer-industries/wanderer/compare/v1.91.8...v1.91.9) (2026-01-06)
### Bug Fixes:
* core: fixed rally point cancel logic
## [v1.91.8](https://github.com/wanderer-industries/wanderer/compare/v1.91.7...v1.91.8) (2026-01-06)
### Bug Fixes:
* core: fixed rally point cancel logic
## [v1.91.7](https://github.com/wanderer-industries/wanderer/compare/v1.91.6...v1.91.7) (2026-01-05)
## [v1.91.6](https://github.com/wanderer-industries/wanderer/compare/v1.91.5...v1.91.6) (2026-01-04)

View File

@@ -281,9 +281,9 @@ const MapComp = ({
deleteKeyCode={['']}
{...(isPanAndDrag
? {
selectionOnDrag: true,
panOnDrag: [2],
}
selectionOnDrag: true,
panOnDrag: [2],
}
: {})}
// TODO need create clear example with problem with that flag
// if system is not visible edge not drawing (and any render in Custom node is not happening)

View File

@@ -13,12 +13,14 @@ export interface ContextMenuRootProps {
pasteSystemsAndConnections: PasteSystemsAndConnections | undefined;
onAddSystem(): void;
onPasteSystemsAnsConnections(): void;
onAutoLayout(): void;
}
export const ContextMenuRoot: React.FC<ContextMenuRootProps> = ({
contextMenuRef,
onAddSystem,
onPasteSystemsAnsConnections,
onAutoLayout,
pasteSystemsAndConnections,
}) => {
const {
@@ -34,35 +36,40 @@ export const ContextMenuRoot: React.FC<ContextMenuRootProps> = ({
icon: PrimeIcons.PLUS,
command: onAddSystem,
},
{
label: 'Auto Layout',
icon: PrimeIcons.REFRESH,
command: onAutoLayout,
},
...(pasteSystemsAndConnections != null
? [
{
icon: 'pi pi-clipboard',
disabled: !allowPaste,
command: onPasteSystemsAnsConnections,
template: () => {
if (allowPaste) {
return (
<WdMenuItem icon="pi pi-clipboard">
Paste
</WdMenuItem>
);
}
{
icon: 'pi pi-clipboard',
disabled: !allowPaste,
command: onPasteSystemsAnsConnections,
template: () => {
if (allowPaste) {
return (
<MenuItemWithInfo
infoTitle="Action is blocked because you dont have permission to Paste."
infoClass={clsx(PrimeIcons.QUESTION_CIRCLE, 'text-stone-500 mr-[12px]')}
tooltipWrapperClassName="flex"
>
<WdMenuItem disabled icon="pi pi-clipboard">
Paste
</WdMenuItem>
</MenuItemWithInfo>
<WdMenuItem icon="pi pi-clipboard">
Paste
</WdMenuItem>
);
},
}
return (
<MenuItemWithInfo
infoTitle="Action is blocked because you dont have permission to Paste."
infoClass={clsx(PrimeIcons.QUESTION_CIRCLE, 'text-stone-500 mr-[12px]')}
tooltipWrapperClassName="flex"
>
<WdMenuItem disabled icon="pi pi-clipboard">
Paste
</WdMenuItem>
</MenuItemWithInfo>
);
},
]
},
]
: []),
];
}, [userPermissions, options, onAddSystem, pasteSystemsAndConnections, onPasteSystemsAnsConnections]);

View File

@@ -46,6 +46,22 @@ export const useContextMenuRootHandlers = ({ onAddSystem, onCommand }: UseContex
ref.current.onAddSystem?.({ coordinates: position });
}, [position]);
const onAutoLayout = useCallback(async () => {
const { onCommand } = ref.current;
if (!onCommand) {
return;
}
console.log('Auto layouting systems');
await onCommand({
type: OutCommand.layoutSystems,
data: {
system_ids: [], // Layout all systems
},
});
}, []);
const onPasteSystemsAnsConnections = useCallback(async () => {
const { pasteSystemsAndConnections, onCommand, position } = ref.current;
if (!position || !onCommand || !pasteSystemsAndConnections) {
@@ -72,5 +88,6 @@ export const useContextMenuRootHandlers = ({ onAddSystem, onCommand }: UseContex
contextMenuRef,
onAddSystem: onAddSystemCallback,
onPasteSystemsAnsConnections,
onAutoLayout,
};
};

View File

@@ -46,6 +46,10 @@
stroke-dasharray: 10 5;
stroke-linecap: round;
}
&.CrossList {
stroke: #ff0000;
}
}
.EdgePathFront {
@@ -93,6 +97,10 @@
stroke-width: 3px;
}
}
&.CrossList {
stroke: #ff0000;
}
}
.ClickPath {

View File

@@ -85,6 +85,7 @@ export const SolarSystemEdge = ({ id, source, target, markerEnd, style, data }:
[classes.Hovered]: hovered,
[classes.Gate]: isGate,
[classes.Bridge]: isBridge,
[classes.CrossList]: data.is_cross_list,
})}
d={path}
markerEnd={markerEnd}
@@ -100,6 +101,7 @@ export const SolarSystemEdge = ({ id, source, target, markerEnd, style, data }:
[classes.Frigate]: isWormhole && data.ship_size_type === ShipSizeStatus.small,
[classes.Gate]: isGate,
[classes.Bridge]: isBridge,
[classes.CrossList]: data.is_cross_list,
})}
d={path}
markerEnd={markerEnd}

View File

@@ -17,8 +17,15 @@ export const useCommandsConnections = () => {
}, []);
const updateConnection = useCallback((value: CommandUpdateConnection) => {
ref.current.rf.deleteElements({ edges: [value] });
ref.current.rf.addEdges([convertConnection2Edge(value)]);
const newEdge = convertConnection2Edge(value);
ref.current.rf.setEdges(eds => {
const exists = eds.find(e => e.id === newEdge.id);
if (exists) {
return eds.map(e => e.id === newEdge.id ? newEdge : e);
} else {
return [...eds, newEdge];
}
});
}, []);
return { addConnections, removeConnections, updateConnection };

View File

@@ -62,7 +62,7 @@ export const useMapHandlers = (ref: ForwardedRef<MapHandlers>, onSelectionChange
setTimeout(() => mapAddSystems(data as CommandAddSystems), 100);
break;
case Commands.updateSystems:
mapUpdateSystems(data as CommandUpdateSystems);
setTimeout(() => mapUpdateSystems(data as CommandUpdateSystems), 100);
break;
case Commands.removeSystems:
setTimeout(() => removeSystems(data as CommandRemoveSystems), 100);
@@ -89,7 +89,7 @@ export const useMapHandlers = (ref: ForwardedRef<MapHandlers>, onSelectionChange
presentCharacters(data as CommandPresentCharacters);
break;
case Commands.updateConnection:
updateConnection(data as CommandUpdateConnection);
setTimeout(() => updateConnection(data as CommandUpdateConnection), 100);
break;
case Commands.mapUpdated:
mapUpdated(data as CommandMapUpdated);

View File

@@ -72,7 +72,7 @@ export const useSolarSystemNode = (props: NodeProps<MapSolarSystemType>): SolarS
const {
storedSettings: { interfaceSettings },
data: { systemSignatures: mapSystemSignatures },
data: { systemSignatures: mapSystemSignatures, pings },
} = useMapRootState();
const systemStaticInfo = useMemo(() => {
@@ -108,7 +108,6 @@ export const useSolarSystemNode = (props: NodeProps<MapSolarSystemType>): SolarS
visibleNodes,
showKSpaceBG,
isThickConnections,
pings,
systemHighlighted,
},
outCommand,

View File

@@ -121,6 +121,7 @@ export const PingsInterface = ({ hasLeftOffset }: PingsInterfaceProps) => {
useEffect(() => {
if (!ping) {
setIsShow(false);
return;
}
@@ -161,27 +162,26 @@ export const PingsInterface = ({ hasLeftOffset }: PingsInterfaceProps) => {
};
}, [interfaceSettings]);
if (!ping) {
return null;
}
const isShowSelectedSystem = selectedSystem != null && selectedSystem !== ping.solar_system_id;
const isShowSelectedSystem = ping && selectedSystem != null && selectedSystem !== ping.solar_system_id;
// Only render Toast when there's a ping
return (
<>
<Toast
position={placement as never}
className={clsx('!max-w-[initial] w-[500px]', hasLeftOffset ? offsets.withLeftMenu : offsets.default)}
ref={toast}
content={({ message }) => (
<section
className={clsx(
'flex flex-col p-3 w-full border border-stone-800 shadow-md animate-fadeInDown rounded-[5px]',
'bg-gradient-to-tr from-transparent to-sky-700/60 bg-stone-900/70',
)}
>
<div className="flex gap-3">
<i className={clsx('pi text-yellow-500 text-2xl', 'relative top-[2px]', ICONS[ping.type])}></i>
{ping && (
<Toast
key={ping.id}
position={placement as never}
className={clsx('!max-w-[initial] w-[500px]', hasLeftOffset ? offsets.withLeftMenu : offsets.default)}
ref={toast}
content={({ message }) => (
<section
className={clsx(
'flex flex-col p-3 w-full border border-stone-800 shadow-md animate-fadeInDown rounded-[5px]',
'bg-gradient-to-tr from-transparent to-sky-700/60 bg-stone-900/70',
)}
>
<div className="flex gap-3">
<i className={clsx('pi text-yellow-500 text-2xl', 'relative top-[2px]', ICONS[ping.type])}></i>
<div className="flex flex-col gap-1 w-full">
<div className="flex justify-between">
<div>
@@ -253,28 +253,33 @@ export const PingsInterface = ({ hasLeftOffset }: PingsInterfaceProps) => {
{/*/>*/}
</div>
</section>
)}
></Toast>
)}
></Toast>
)}
<WdButton
icon="pi pi-bell"
severity="warning"
aria-label="Notification"
size="small"
className="w-[33px] h-[33px]"
outlined
onClick={handleClickShow}
disabled={isShow}
/>
{ping && (
<>
<WdButton
icon="pi pi-bell"
severity="warning"
aria-label="Notification"
size="small"
className="w-[33px] h-[33px]"
outlined
onClick={handleClickShow}
disabled={isShow}
/>
<ConfirmPopup
target={cfRef.current}
visible={cfVisible}
onHide={cfHide}
message="Are you sure you want to delete ping?"
icon="pi pi-exclamation-triangle text-orange-400"
accept={removePing}
/>
<ConfirmPopup
target={cfRef.current}
visible={cfVisible}
onHide={cfHide}
message="Are you sure you want to delete ping?"
icon="pi pi-exclamation-triangle text-orange-400"
accept={removePing}
/>
</>
)}
</>
);
};

View File

@@ -3,9 +3,9 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useSystemInfo } from '@/hooks/Mapper/components/hooks';
import {
SOLAR_SYSTEM_CLASS_IDS,
SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS,
WORMHOLES_ADDITIONAL_INFO_BY_SHORT_NAME,
SOLAR_SYSTEM_CLASS_IDS,
SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS,
WORMHOLES_ADDITIONAL_INFO_BY_SHORT_NAME,
} from '@/hooks/Mapper/components/map/constants.ts';
import { SystemSignaturesContent } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignaturesContent';
import { K162_TYPES_MAP } from '@/hooks/Mapper/constants.ts';
@@ -91,7 +91,7 @@ export const SystemLinkSignatureDialog = ({ data, setVisible }: SystemLinkSignat
if (k162TypeInfo) {
// Check if the k162Type matches our target system class
return customInfo.k162Type === targetSystemClassGroup;
return k162TypeInfo.value.includes(targetSystemClassGroup);
}
}

View File

@@ -13,6 +13,26 @@ export const renderK162Type = (option: K162Type) => {
return renderNoValue();
}
if (['c1_c2_c3', 'c4_c5'].includes(value)) {
const arr = whClassName.split('_');
return (
<div className="flex gap-1 items-center">
{arr.map(x => (
<WHClassView
key={x}
classNameWh="!text-[11px] !font-bold"
hideWhClassName
hideTooltip
whClassName={x}
noOffset
useShortTitle
/>
))}
</div>
);
}
return (
<WHClassView
classNameWh="!text-[11px] !font-bold"

View File

@@ -88,6 +88,16 @@ export const K162_TYPES: K162Type[] = [
value: 'ns',
whClassName: 'C248',
},
{
label: 'C1/C2/C3',
value: 'c1_c2_c3',
whClassName: 'E004_D382_L477',
},
{
label: 'C4/C5',
value: 'c4_c5',
whClassName: 'M001_L614',
},
{
label: 'C1',
value: 'c1',

View File

@@ -10,3 +10,4 @@ export * from './useCommandComments';
export * from './useGetCacheCharacter';
export * from './useCommandsActivity';
export * from './useCommandPings';
export * from './useCommandPingBlocked';

View File

@@ -0,0 +1,21 @@
import { useToast } from '@/hooks/Mapper/ToastProvider';
import { CommandPingBlocked } from '@/hooks/Mapper/types';
import { useCallback } from 'react';
export const useCommandPingBlocked = () => {
const { show } = useToast();
const pingBlocked = useCallback(
({ message }: CommandPingBlocked) => {
show({
severity: 'warn',
summary: 'Cannot create ping',
detail: message,
life: 5000,
});
},
[show],
);
return { pingBlocked };
};

View File

@@ -14,8 +14,8 @@ export const useCommandPings = () => {
ref.current.update({ pings });
}, []);
const pingCancelled = useCallback(({ type, id }: CommandPingCancelled) => {
const newPings = ref.current.pings.filter(x => x.id !== id && x.type !== type);
const pingCancelled = useCallback(({ id }: CommandPingCancelled) => {
const newPings = ref.current.pings.filter(x => x.id !== id);
ref.current.update({ pings: newPings });
}, []);

View File

@@ -63,7 +63,6 @@ export const useComments = ({ outCommand }: UseCommentsProps): UseCommentsData =
const removeComment = useCallback((systemId: number, commentId: string) => {
const cSystem = commentBySystemsRef.current.get(systemId);
console.log('cSystem', cSystem);
if (!cSystem) {
return;
}

View File

@@ -12,6 +12,7 @@ import {
CommandLinkSignatureToSystem,
CommandMapUpdated,
CommandPingAdded,
CommandPingBlocked,
CommandPingCancelled,
CommandPresentCharacters,
CommandRemoveConnections,
@@ -29,6 +30,7 @@ import { ForwardedRef, useImperativeHandle } from 'react';
import {
useCommandComments,
useCommandPingBlocked,
useCommandPings,
useCommandsCharacters,
useCommandsConnections,
@@ -61,6 +63,7 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
const mapUserRoutes = useUserRoutes();
const { addComment, removeComment } = useCommandComments();
const { pingAdded, pingCancelled } = useCommandPings();
const { pingBlocked } = useCommandPingBlocked();
const { characterActivityData, trackingCharactersData, userSettingsUpdated } = useCommandsActivity();
useImperativeHandle(ref, () => {
@@ -172,6 +175,9 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
case Commands.pingCancelled:
pingCancelled(data as CommandPingCancelled);
break;
case Commands.pingBlocked:
pingBlocked(data as CommandPingBlocked);
break;
default:
console.warn(`JOipP Interface handlers: Unknown command: ${type}`, data);
break;

View File

@@ -41,4 +41,5 @@ export type SolarSystemConnection = {
target: string;
type?: ConnectionType;
is_cross_list?: boolean;
};

View File

@@ -41,6 +41,7 @@ export enum Commands {
refreshTrackingData = 'refresh_tracking_data',
pingAdded = 'ping_added',
pingCancelled = 'ping_cancelled',
pingBlocked = 'ping_blocked',
}
export type Command =
@@ -77,7 +78,8 @@ export type Command =
| Commands.showTracking
| Commands.refreshTrackingData
| Commands.pingAdded
| Commands.pingCancelled;
| Commands.pingCancelled
| Commands.pingBlocked;
export type CommandInit = {
systems: SolarSystemRawType[];
@@ -161,6 +163,10 @@ export type CommandUpdateTracking = {
};
export type CommandPingAdded = PingData[];
export type CommandPingCancelled = Pick<PingData, 'type' | 'id'>;
export type CommandPingBlocked = {
reason: string;
message: string;
};
export interface UserSettings {
primaryCharacterId?: string;
@@ -212,6 +218,7 @@ export interface CommandData {
[Commands.refreshTrackingData]: CommandRefreshTrackingData;
[Commands.pingAdded]: CommandPingAdded;
[Commands.pingCancelled]: CommandPingCancelled;
[Commands.pingBlocked]: CommandPingBlocked;
}
export interface MapHandlers {
@@ -274,6 +281,7 @@ export enum OutCommand {
addPing = 'add_ping',
cancelPing = 'cancel_ping',
startTracking = 'startTracking',
layoutSystems = 'layout_systems',
// Only UI commands
openSettings = 'open_settings',

12939
assets/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -80,6 +80,10 @@ defmodule WandererApp.Api.MapPing do
filter(expr(inserted_at <= ^arg(:inserted_before)))
end
# Admin action for cleanup - no actor filtering
read :all_pings do
end
end
attributes do

View File

@@ -16,7 +16,7 @@ defmodule WandererApp.Map.Manager do
@maps_queue :maps_queue
@check_maps_queue_interval :timer.seconds(1)
@pings_cleanup_interval :timer.minutes(10)
@pings_cleanup_interval :timer.minutes(5)
@pings_expire_minutes 60
# Test-aware async task runner
@@ -99,6 +99,7 @@ defmodule WandererApp.Map.Manager do
def handle_info(:cleanup_pings, state) do
try do
cleanup_expired_pings()
cleanup_orphaned_pings()
{:noreply, state}
rescue
e ->
@@ -141,6 +142,51 @@ defmodule WandererApp.Map.Manager do
end
end
defp cleanup_orphaned_pings() do
case WandererApp.MapPingsRepo.get_orphaned_pings() do
{:ok, []} ->
:ok
{:ok, orphaned_pings} ->
Logger.info(
"[cleanup_orphaned_pings] Found #{length(orphaned_pings)} orphaned pings, cleaning up..."
)
Enum.each(orphaned_pings, fn %{id: ping_id, map_id: map_id, type: type, system: system} = ping ->
reason =
cond do
is_nil(ping.system) -> "system deleted"
is_nil(ping.character) -> "character deleted"
is_nil(ping.map) -> "map deleted"
not is_nil(system) and system.visible == false -> "system hidden (visible=false)"
true -> "unknown"
end
Logger.warning(
"[cleanup_orphaned_pings] Destroying orphaned ping #{ping_id} (map_id: #{map_id}, reason: #{reason})"
)
# Broadcast cancellation if map_id is still valid
if map_id do
Server.Impl.broadcast!(map_id, :ping_cancelled, %{
id: ping_id,
solar_system_id: nil,
type: type
})
end
Ash.destroy!(ping)
end)
Logger.info("[cleanup_orphaned_pings] Cleaned up #{length(orphaned_pings)} orphaned pings")
:ok
{:error, error} ->
Logger.error("Failed to fetch orphaned pings: #{inspect(error)}")
{:error, error}
end
end
defp start_maps() do
chunks =
@maps_queue

View File

@@ -119,4 +119,325 @@ defmodule WandererApp.Map.PositionCalculator do
}
end)
end
# Layout systems
def layout_systems(systems, connections, opts) do
Logger.info("Layouting systems with #{length(systems)} systems and #{length(connections)} connections")
system_ids = Enum.map(systems, & &1.solar_system_id)
system_props = systems |> Enum.map(&{&1.solar_system_id, &1}) |> Map.new()
# Build undirected adjacency list for component finding and traversal
undirected_adj = connections
|> Enum.reduce(%{}, fn %{solar_system_source: s, solar_system_target: t}, acc ->
if s in system_ids and t in system_ids do
acc
|> Map.update(s, [t], &[t | &1])
|> Map.update(t, [s], &[s | &1])
else
acc
end
end)
# Find connected components
components = find_components(system_ids, undirected_adj)
# 1. Identify all roots for each component
all_roots = components
|> Enum.flat_map(&find_roots(&1, system_props))
|> Enum.uniq()
|> Enum.sort_by(&get_system_name(Map.get(system_props, &1)))
all_roots_set = MapSet.new(all_roots)
# 2. Pre-calculate root claims using Priority-based expansion (Oldest connections first)
{root_claims, tree_edge_ids} = build_root_claims(all_roots_set, undirected_adj, connections)
# 3. Build tree adjacency list for traversal to ensure naming/positioning strictly follow tree logic
tree_adj = connections
|> Enum.reduce(%{}, fn conn, acc ->
if MapSet.member?(tree_edge_ids, conn.id) do
s = conn.solar_system_source
t = conn.solar_system_target
acc
|> Map.update(s, [t], &[t | &1])
|> Map.update(t, [s], &[s | &1])
else
acc
end
end)
# 4. Layout each root sequentially
layout_type = (opts |> Keyword.get(:layout, "left_to_right")) |> String.to_atom()
{final_positions, hierarchical_names, _next_breadth, _visited} = all_roots
|> Enum.reduce({%{}, %{}, 0.0, MapSet.new()}, fn root_id, {pos_acc, name_acc, cur_breadth, visited_acc} ->
if MapSet.member?(visited_acc, root_id) do
{pos_acc, name_acc, cur_breadth, visited_acc}
else
{start_x, start_y} = case layout_type do
:top_to_bottom -> {cur_breadth, 0.0}
_ -> {0.0, cur_breadth}
end
# Recursive layout starting from this root, strictly following its claimed tree edges
{subtree_pos, subtree_names, subtree_breadth, new_visited} = do_recursive_layout(root_id, start_x, start_y, tree_adj, system_props, visited_acc, root_claims, "0", layout_type)
# Use a larger margin between root subtrees
margin = case layout_type do
:top_to_bottom -> @m_x * 3
_ -> @m_y * 3
end
{Map.merge(pos_acc, subtree_pos), Map.merge(name_acc, subtree_names), cur_breadth + subtree_breadth + margin, new_visited}
end
end)
# 5. Detect special connections (cross-list or cycle) and affected systems
{special_conn_ids, affected_roots} = connections
|> Enum.reduce({[], MapSet.new()}, fn conn, {ids_acc, roots_acc} ->
if not MapSet.member?(tree_edge_ids, conn.id) do
# This is a special connection (cycle or cross-list)
root_s = Map.get(root_claims, conn.solar_system_source)
root_t = Map.get(root_claims, conn.solar_system_target)
new_roots_acc = roots_acc
new_roots_acc = if root_s, do: MapSet.put(new_roots_acc, root_s), else: new_roots_acc
new_roots_acc = if root_t, do: MapSet.put(new_roots_acc, root_t), else: new_roots_acc
Logger.info("[PositionCalculator] Special connection detected (Cycle/Cross-List): #{get_system_name(system_props[conn.solar_system_source])} <-> #{get_system_name(system_props[conn.solar_system_target])}. Skipping affected components.")
{[conn.id | ids_acc], new_roots_acc}
else
{ids_acc, roots_acc}
end
end)
# Find all systems that belong to any affected root subtree
skipped_system_ids = root_claims
|> Enum.filter(fn {_, root_id} -> MapSet.member?(affected_roots, root_id) end)
|> Enum.map(fn {sid, _} -> sid end)
|> MapSet.new()
updated_systems = systems
|> Enum.map(fn %{solar_system_id: id} = system ->
is_skipped = MapSet.member?(skipped_system_ids, id)
system = if is_skipped do
# Skip: keep original positions
system
else
{x, y} = Map.get(final_positions, id, {float(system.position_x), float(system.position_y)})
%{system | position_x: round(x), position_y: round(y)}
end
# Always attach the hierarchical name if it was calculated AND not skipped
case Map.get(hierarchical_names, id) do
h_name when not is_nil(h_name) and not is_skipped -> Map.put(system, :hierarchical_name, h_name)
_ -> system
end
end)
{updated_systems, special_conn_ids}
end
defp find_roots(component_ids, system_props) do
component_systems = Enum.map(component_ids, &Map.get(system_props, &1))
locked_roots = component_systems
|> Enum.filter(&Map.get(&1, :locked, false))
|> Enum.map(& &1.solar_system_id)
if Enum.empty?(locked_roots) do
# Fallback: take alphabetical first according to criteria
component_systems
|> Enum.sort_by(&get_system_name/1)
|> List.first()
|> Map.get(:solar_system_id)
|> List.wrap()
else
locked_roots
end
end
defp get_system_name(nil), do: ""
defp get_system_name(system) do
Map.get(system, :name) || Map.get(system, :temporary_name) || (system.solar_system_id |> Integer.to_string())
end
defp find_components(ids, adj) do
ids
|> Enum.reduce({[], MapSet.new()}, fn id, {components, visited} ->
if MapSet.member?(visited, id) do
{components, visited}
else
{component, new_visited} = bfs_component(id, adj)
{[component | components], MapSet.union(visited, new_visited)}
end
end)
|> elem(0)
end
defp bfs_component(start_id, adj) do
queue = :queue.from_list([start_id])
do_bfs_component(queue, adj, MapSet.new())
end
defp do_bfs_component(queue, adj, visited) do
case :queue.out(queue) do
{{:value, id}, q} ->
if MapSet.member?(visited, id) do
do_bfs_component(q, adj, visited)
else
visited = MapSet.put(visited, id)
neighbors = Map.get(adj, id, [])
q = Enum.reduce(neighbors, q, &:queue.in(&1, &2))
do_bfs_component(q, adj, visited)
end
{:empty, _} ->
{MapSet.to_list(visited), visited}
end
end
defp do_recursive_layout(id, x, y, adj, system_props, visited, root_claims, path, layout \\ :left_to_right) do
if MapSet.member?(visited, id) do
{%{}, %{}, 0.0, visited}
else
system = Map.get(system_props, id)
# Determine actual axes based on orientation
{actual_x, actual_y} = case layout do
:top_to_bottom ->
ay = if Map.get(system, :locked, false) and path != "0", do: float(system.position_y), else: y
{x, ay}
_ ->
ax = if Map.get(system, :locked, false) and path != "0", do: float(system.position_x), else: x
{ax, y}
end
visited = MapSet.put(visited, id)
# Determine current root for this node
root_id = Map.get(root_claims, id)
# Follow neighbors that belong to the SAME root claim and are not yet visited
# (adj already only contains tree edges)
children = Map.get(adj, id, [])
|> Enum.filter(&(Map.get(root_claims, &1) == root_id))
|> Enum.reject(&MapSet.member?(visited, &1))
|> Enum.sort_by(&get_system_name(Map.get(system_props, &1)))
current_name_map = %{id => path}
if Enum.empty?(children) do
branch_breadth = case layout do
:top_to_bottom -> float(@w)
_ -> float(@h)
end
{%{id => {actual_x, actual_y}}, current_name_map, branch_breadth, visited}
else
# Layout children sequentially based on orientation
{children_pos, children_names, total_children_breadth, new_visited} = children
|> Enum.with_index(1)
|> Enum.reduce({%{}, %{}, 0.0, visited}, fn {child_id, index}, {acc_pos, acc_names, acc_b, acc_visited} ->
child_path = if path == "0", do: "#{index}", else: "#{path}-#{index}"
{child_x, child_y} = case layout do
:top_to_bottom -> {actual_x + acc_b, actual_y + @h + @m_y}
_ -> {actual_x + @w + @m_x, actual_y + acc_b}
end
{c_pos, c_names, c_b, c_v} = do_recursive_layout(child_id, child_x, child_y, adj, system_props, acc_visited, root_claims, child_path, layout)
step_margin = case layout do
:top_to_bottom -> @m_x
_ -> @m_y
end
{Map.merge(acc_pos, c_pos), Map.merge(acc_names, c_names), acc_b + c_b + step_margin, c_v}
end)
margin_correction = case layout do
:top_to_bottom -> @m_x
_ -> @m_y
end
total_children_breadth = if total_children_breadth > 0, do: total_children_breadth - margin_correction, else: 0.0
node_pos = %{id => {actual_x, actual_y}}
node_breadth = case layout do
:top_to_bottom -> float(@w)
_ -> float(@h)
end
result_breadth = Enum.max([node_breadth, total_children_breadth])
{Map.merge(node_pos, children_pos), Map.merge(current_name_map, children_names), result_breadth, new_visited}
end
end
end
defp build_root_claims(all_roots_set, adj, connections) do
# Map connections to neutral keys {s, t} for quick age lookup
conn_map = connections
|> Enum.flat_map(fn c ->
s = c.solar_system_source
t = c.solar_system_target
key = if s < t, do: {s, t}, else: {t, s}
[{key, c}]
end)
|> Map.new()
initial_claims = all_roots_set |> MapSet.to_list() |> Enum.map(&{&1, &1}) |> Map.new()
# Priority-based search frontier
# We sort all roots by name to have deterministic start
sorted_roots = all_roots_set |> MapSet.to_list() |> Enum.sort_by(& Integer.to_string(&1))
initial_frontier = sorted_roots
|> Enum.flat_map(fn rid ->
Map.get(adj, rid, [])
|> Enum.reject(&Map.has_key?(initial_claims, &1))
|> Enum.map(fn neighbor_id ->
key = if rid < neighbor_id, do: {rid, neighbor_id}, else: {neighbor_id, rid}
conn = Map.get(conn_map, key)
inserted_at = Map.get(conn, :inserted_at) || ~U[2099-01-01 00:00:00Z]
sort_key = {inserted_at, Map.get(conn, :id, "")}
{sort_key, neighbor_id, rid, conn.id}
end)
end)
do_build_claims(initial_frontier, adj, conn_map, initial_claims, MapSet.new())
end
# Simple priority-based expansion search
defp do_build_claims([], _adj, _conn_map, claims, tree_edge_ids), do: {claims, tree_edge_ids}
defp do_build_claims(frontier, adj, conn_map, claims, tree_edge_ids) do
# Sort frontier by sort_key (age ASC, then ID ASC)
[{_key, node_id, root_id, conn_id} | rest_frontier] = Enum.sort_by(frontier, fn {k, _, _, _} -> k end)
if Map.has_key?(claims, node_id) do
do_build_claims(rest_frontier, adj, conn_map, claims, tree_edge_ids)
else
new_claims = Map.put(claims, node_id, root_id)
new_tree_edge_ids = MapSet.put(tree_edge_ids, conn_id)
# Add neighbors to frontier
new_neighbors = Map.get(adj, node_id, [])
|> Enum.reject(&Map.has_key?(new_claims, &1))
|> Enum.map(fn neighbor_id ->
key = if node_id < neighbor_id, do: {node_id, neighbor_id}, else: {neighbor_id, node_id}
conn = Map.get(conn_map, key)
inserted_at = Map.get(conn, :inserted_at) || ~U[2099-01-01 00:00:00Z]
sort_key = {inserted_at, Map.get(conn, :id, "")}
{sort_key, neighbor_id, root_id, conn.id}
end)
do_build_claims(rest_frontier ++ new_neighbors, adj, conn_map, new_claims, new_tree_edge_ids)
end
end
defp float(v) when is_integer(v), do: v * 1.0
defp float(v), do: v
end

View File

@@ -72,6 +72,8 @@ defmodule WandererApp.Map.Server do
defdelegate delete_systems(map_id, solar_system_ids, user_id, character_id), to: Impl
defdelegate layout_systems(map_id, system_ids), to: Impl
defdelegate add_connection(map_id, connection_info), to: Impl
defdelegate delete_connection(map_id, connection_info), to: Impl

View File

@@ -78,7 +78,8 @@ defmodule WandererApp.Map.Operations.Signatures do
)
when is_integer(solar_system_id) do
with {:ok, validated_char_uuid} <- validate_character_eve_id(params, char_id),
{:ok, system} <- MapSystem.by_map_id_and_solar_system_id(map_id, solar_system_id) do
{:ok, system} <-
MapSystem.read_by_map_and_solar_system(%{map_id: map_id, solar_system_id: solar_system_id}) do
attrs =
params
|> Map.put("system_id", system.id)

View File

@@ -151,7 +151,8 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
solar_system_source_id: solar_system_source_id,
solar_system_target_id: solar_system_target_id,
character_id: character_id
} = connection_info
} = connection_info,
retrigger_layout \\ true
),
do:
maybe_add_connection(
@@ -162,7 +163,8 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
},
character_id,
true,
connection_info |> Map.get(:extra_info)
connection_info |> Map.get(:extra_info),
retrigger_layout
)
def paste_connections(
@@ -179,13 +181,20 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
solar_system_source_id = source |> String.to_integer()
solar_system_target_id = target |> String.to_integer()
# Disable retrigger_layout for each individual connection
add_connection(map_id, %{
solar_system_source_id: solar_system_source_id,
solar_system_target_id: solar_system_target_id,
character_id: character_id,
extra_info: connection
})
}, false)
end)
# Retrigger layout once at the end if auto_layout is on
{:ok, %{map_opts: map_opts}} = WandererApp.Map.get_map_state(map_id)
if Keyword.get(map_opts, :auto_layout, false) do
SystemsImpl.layout_systems(map_id, nil)
end
end
def delete_connection(
@@ -534,7 +543,18 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
old_location,
character_id,
is_manual,
extra_info
extra_info,
retrigger_layout \\ true
)
def maybe_add_connection(
map_id,
location,
old_location,
character_id,
is_manual,
extra_info,
retrigger_layout
)
when not is_nil(location) and not is_nil(old_location) and
not is_nil(old_location.solar_system_id) and
@@ -646,6 +666,13 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
solar_system_target_id: location.solar_system_id
})
if retrigger_layout do
{:ok, %{map_opts: map_opts}} = WandererApp.Map.get_map_state(map_id)
if Keyword.get(map_opts, :auto_layout, false) do
SystemsImpl.layout_systems(map_id, nil)
end
end
:ok
{:error, :already_exists} ->
@@ -670,7 +697,8 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
_old_location,
_character_id,
_is_manual,
_connection_extra_info
_connection_extra_info,
_retrigger_layout
),
do: :ok

View File

@@ -257,6 +257,8 @@ defmodule WandererApp.Map.Server.Impl do
defdelegate update_connection_custom_info(map_id, connection_update), to: ConnectionsImpl
defdelegate update_signatures(map_id, signatures_update), to: SignaturesImpl
defdelegate layout_systems(map_id, system_ids), to: SystemsImpl
def import_settings(map_id, settings, user_id) do
WandererApp.Cache.put(
"map_#{map_id}:importing",
@@ -477,7 +479,8 @@ defmodule WandererApp.Map.Server.Impl do
restrict_offline_showing:
options |> Map.get("restrict_offline_showing", "false") |> String.to_existing_atom(),
allowed_copy_for: options |> Map.get("allowed_copy_for", "admin"),
allowed_paste_for: options |> Map.get("allowed_paste_for", "member")
allowed_paste_for: options |> Map.get("allowed_paste_for", "member"),
auto_layout: options |> Map.get("auto_layout", "false") |> String.to_existing_atom()
]
end

View File

@@ -72,17 +72,24 @@ defmodule WandererApp.Map.Server.PingsImpl do
type: type
} = _ping_info
) do
case WandererApp.MapPingsRepo.get_by_id(ping_id) do
result = WandererApp.MapPingsRepo.get_by_id(ping_id)
case result do
{:ok,
%{system: %{id: system_id, name: system_name, solar_system_id: solar_system_id}} = ping} ->
with {:ok, character} <- WandererApp.Character.get_character(character_id),
:ok <- WandererApp.MapPingsRepo.destroy(ping) do
Logger.debug("Ping #{ping_id} destroyed successfully, broadcasting :ping_cancelled")
Impl.broadcast!(map_id, :ping_cancelled, %{
id: ping_id,
solar_system_id: solar_system_id,
type: type
})
Logger.debug("Broadcast :ping_cancelled sent for ping #{ping_id}")
# Broadcast rally point removal events to external clients (webhooks/SSE)
if type == 1 do
WandererApp.ExternalEvents.broadcast(map_id, :rally_point_removed, %{
@@ -107,18 +114,45 @@ defmodule WandererApp.Map.Server.PingsImpl do
Logger.error("Failed to destroy ping: #{inspect(error, pretty: true)}")
end
# Handle case where ping exists but system was deleted (nil)
{:ok, %{system: nil} = ping} ->
case WandererApp.MapPingsRepo.destroy(ping) do
:ok ->
Impl.broadcast!(map_id, :ping_cancelled, %{
id: ping_id,
solar_system_id: nil,
type: type
})
error ->
Logger.error("Failed to destroy orphaned ping: #{inspect(error, pretty: true)}")
end
{:error, %Ash.Error.Query.NotFound{}} ->
# Ping already deleted (possibly by cascade deletion from map/system/character removal,
# auto-expiry, or concurrent cancellation). This is not an error - the desired state
# (ping is gone) is already achieved. Just broadcast the cancellation event.
Logger.debug(
"Ping #{ping_id} not found during cancellation - already deleted, skipping broadcast"
)
# auto-expiry, or concurrent cancellation). Broadcast cancellation so frontend updates.
Impl.broadcast!(map_id, :ping_cancelled, %{
id: ping_id,
solar_system_id: nil,
type: type
})
:ok
error ->
Logger.error("Failed to fetch ping for cancellation: #{inspect(error, pretty: true)}")
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
# Same as above, but Ash wraps NotFound inside Invalid in some cases
Impl.broadcast!(map_id, :ping_cancelled, %{
id: ping_id,
solar_system_id: nil,
type: type
})
:ok
other ->
Logger.error(
"Failed to cancel ping #{ping_id}: unexpected result from get_by_id: #{inspect(other, pretty: true)}"
)
end
end
end

View File

@@ -167,6 +167,9 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
updated_count: length(updated_ids),
removed_count: length(removed_ids)
})
# Always return :ok - external event failures should not affect the main operation
:ok
end
defp remove_signature(map_id, sig, system, delete_conn?) do

View File

@@ -283,6 +283,72 @@ defmodule WandererApp.Map.Server.SystemsImpl do
end
)
def layout_systems(map_id, system_ids) do
{:ok, all_systems} = WandererApp.Map.list_systems(map_id)
{:ok, connections} = WandererApp.Map.list_connections(map_id)
Logger.info("Layouting systems for map #{map_id} with system_ids #{inspect(system_ids)}")
systems_to_layout =
case system_ids do
nil -> all_systems
[] -> all_systems
ids -> all_systems |> Enum.filter(fn %{solar_system_id: sid} -> sid in ids end)
end
{:ok, %{map_opts: map_opts}} = WandererApp.Map.get_map_state(map_id)
{updated_systems, cross_list_conn_ids} =
WandererApp.Map.PositionCalculator.layout_systems(
systems_to_layout,
connections,
map_opts
)
show_temp_system_name = map_opts |> Keyword.get(:show_temp_system_name, false)
updated_systems
|> Enum.each(fn updated_system ->
hierarchical_name = Map.get(updated_system, :hierarchical_name)
if show_temp_system_name and not is_nil(hierarchical_name) and hierarchical_name != "0" do
update_system_temporary_name(map_id, %{
solar_system_id: updated_system.solar_system_id,
temporary_name: hierarchical_name
})
end
update_system_position(map_id, %{
solar_system_id: updated_system.solar_system_id,
position_x: updated_system.position_x,
position_y: updated_system.position_y
})
end)
connections
|> Enum.each(fn conn ->
is_cross_list = conn.id in cross_list_conn_ids
current_info = conn.custom_info || "{}"
new_info =
case Jason.decode(current_info) do
{:ok, info_map} when is_map(info_map) ->
info_map |> Map.put("is_cross_list", is_cross_list) |> Jason.encode!()
_ ->
Jason.encode!(%{"is_cross_list" => is_cross_list})
end
if new_info != current_info do
{:ok, updated_conn} =
WandererApp.MapConnectionRepo.update_custom_info(conn, %{custom_info: new_info})
WandererApp.Map.update_connection(map_id, updated_conn)
Impl.broadcast!(map_id, :update_connection, updated_conn)
end
end)
end
def add_hub(
map_id,
hub_info

View File

@@ -29,6 +29,34 @@ defmodule WandererApp.MapPingsRepo do
def get_by_inserted_before(inserted_before_date),
do: WandererApp.Api.MapPing.by_inserted_before(inserted_before_date)
@doc """
Returns all pings that have orphaned relationships (nil system, character, or map)
or where the system has been soft-deleted (visible = false).
These pings should be cleaned up as they can no longer be properly displayed or cancelled.
"""
def get_orphaned_pings() do
# Use :all_pings action which has no actor filtering (unlike primary :read)
case WandererApp.Api.MapPing |> Ash.Query.for_read(:all_pings) |> Ash.read() do
{:ok, pings} ->
# Load relationships and filter for orphaned ones
orphaned =
pings
|> Enum.map(fn ping ->
{:ok, loaded} = ping |> Ash.load([:system, :character, :map], authorize?: false)
loaded
end)
|> Enum.filter(fn ping ->
is_nil(ping.system) or is_nil(ping.character) or is_nil(ping.map) or
(not is_nil(ping.system) and ping.system.visible == false)
end)
{:ok, orphaned}
error ->
error
end
end
def create(ping), do: ping |> WandererApp.Api.MapPing.new()
def create!(ping), do: ping |> WandererApp.Api.MapPing.new!()
@@ -38,4 +66,24 @@ defmodule WandererApp.MapPingsRepo do
:ok
end
@doc """
Deletes all pings for a given map. Use with caution - for cleanup purposes.
"""
def delete_all_for_map(map_id) do
case get_by_map(map_id) do
{:ok, pings} ->
Logger.info("[MapPingsRepo] Deleting #{length(pings)} pings for map #{map_id}")
Enum.each(pings, fn ping ->
Logger.info("[MapPingsRepo] Deleting ping #{ping.id} (type: #{ping.type})")
Ash.destroy!(ping)
end)
{:ok, length(pings)}
error ->
error
end
end
end

View File

@@ -11,7 +11,8 @@ defmodule WandererApp.MapRepo do
"show_temp_system_name" => "false",
"restrict_offline_showing" => "false",
"allowed_copy_for" => "admin_map",
"allowed_paste_for" => "add_system"
"allowed_paste_for" => "add_system",
"auto_layout" => "false"
}
def get(map_id, relationships \\ []) do

View File

@@ -29,6 +29,34 @@
id="characters-list"
class="w-full h-full col-span-2 lg:col-span-1 p-4 pl-20 pb-20 overflow-auto"
>
<div class="flex items-center justify-between gap-4 px-4 py-2 mb-4 bg-stone-900/60 border border-stone-800 rounded">
<div class="flex items-center gap-3">
<.icon name="hero-gift-solid" class="w-4 h-4 text-green-400 flex-shrink-0" />
<span class="text-sm text-gray-300">
Support development by using promocode
<code class="ml-1 px-1.5 py-0.5 bg-stone-800 rounded text-green-400 text-xs font-mono">WANDERER</code>
<span class="ml-1">at official</span>
</span>
<a
href="https://store.eveonline.com/"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm text-green-400 hover:text-green-300 transition-colors"
>
<span>EVE Online Store</span>
<.icon name="hero-arrow-top-right-on-square-mini" class="w-3 h-3" />
</a>
</div>
<a
href="https://wanderer.ltd/news"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 px-3 py-1 text-sm text-white rounded bg-gradient-to-r from-stone-700 to-stone-600 hover:from-stone-600 hover:to-stone-500 transition-all duration-300 animate-pulse hover:animate-none"
>
<.icon name="hero-newspaper-solid" class="w-3.5 h-3.5" />
<span>Check Latest News</span>
</a>
</div>
<div
:if={@show_characters_add_alert}
role="alert"

View File

@@ -51,14 +51,18 @@ defmodule WandererAppWeb.MapPingsEventHandler do
map_ui_ping(ping_info)
])
def handle_server_event(%{event: :ping_cancelled, payload: ping_info}, socket),
do:
socket
|> MapEventHandler.push_map_event("ping_cancelled", %{
id: ping_info.id,
solar_system_id: ping_info.solar_system_id,
type: ping_info.type
})
def handle_server_event(%{event: :ping_cancelled, payload: ping_info}, socket) do
Logger.debug(
"handle_server_event :ping_cancelled - id: #{ping_info.id}, is_version_valid?: #{inspect(socket.assigns[:is_version_valid?])}"
)
socket
|> MapEventHandler.push_map_event("ping_cancelled", %{
id: ping_info.id,
solar_system_id: ping_info.solar_system_id,
type: ping_info.type
})
end
def handle_server_event(event, socket),
do: MapCoreEventHandler.handle_server_event(event, socket)
@@ -81,12 +85,41 @@ defmodule WandererAppWeb.MapPingsEventHandler do
when not is_nil(main_character_id) do
{:ok, pings} = WandererApp.MapPingsRepo.get_by_map(map_id)
no_exisiting_pings =
# Filter out orphaned pings (system/character deleted or system hidden)
# These should not block new ping creation
valid_pings =
pings
|> Enum.filter(fn ping ->
not is_nil(ping.system) and not is_nil(ping.character) and
(is_nil(ping.system.visible) or ping.system.visible == true)
end)
existing_rally_pings =
valid_pings
|> Enum.filter(fn %{type: type} ->
type == 1
end)
|> Enum.empty?()
no_exisiting_pings = Enum.empty?(existing_rally_pings)
orphaned_count = length(pings) - length(valid_pings)
# Log detailed info about existing pings for debugging
if length(existing_rally_pings) > 0 do
ping_details =
existing_rally_pings
|> Enum.map(fn p ->
"id=#{p.id}, type=#{p.type}, system_id=#{inspect(p.system_id)}, character_id=#{inspect(p.character_id)}, inserted_at=#{p.inserted_at}"
end)
|> Enum.join("; ")
Logger.warning(
"add_ping BLOCKED: map_id=#{map_id}, existing_rally_pings=#{length(existing_rally_pings)}: [#{ping_details}]"
)
else
Logger.debug(
"add_ping check: map_id=#{map_id}, total_pings=#{length(pings)}, valid_pings=#{length(valid_pings)}, orphaned=#{orphaned_count}, rally_pings=0, can_create=true"
)
end
if no_exisiting_pings do
map_id
@@ -97,9 +130,16 @@ defmodule WandererAppWeb.MapPingsEventHandler do
character_id: main_character_id,
user_id: current_user.id
})
end
{:noreply, socket}
{:noreply, socket}
else
{:noreply,
socket
|> MapEventHandler.push_map_event("ping_blocked", %{
reason: "rally_point_exists",
message: "A rally point already exists on this map"
})}
end
end
def handle_ui_event(
@@ -128,6 +168,80 @@ defmodule WandererAppWeb.MapPingsEventHandler do
{:noreply, socket}
end
# Catch add_ping when main_character_id is nil
def handle_ui_event(
"add_ping",
_event,
%{assigns: %{main_character_id: nil}} = socket
) do
{:noreply,
socket
|> MapEventHandler.push_map_event("ping_blocked", %{
reason: "no_main_character",
message: "Please select a main character to create pings"
})}
end
# Catch add_ping when has_tracked_characters? is false
def handle_ui_event(
"add_ping",
_event,
%{assigns: %{has_tracked_characters?: false}} = socket
) do
{:noreply,
socket
|> MapEventHandler.push_map_event("ping_blocked", %{
reason: "no_tracked_characters",
message: "Please add a tracked character to create pings"
})}
end
# Catch add_ping when subscription is not active
def handle_ui_event(
"add_ping",
_event,
%{assigns: %{is_subscription_active?: false}} = socket
) do
{:noreply,
socket
|> MapEventHandler.push_map_event("ping_blocked", %{
reason: "subscription_inactive",
message: "Map subscription is not active"
})}
end
# Catch add_ping when user doesn't have update_system permission
def handle_ui_event(
"add_ping",
_event,
%{assigns: %{user_permissions: %{update_system: false}}} = socket
) do
{:noreply,
socket
|> MapEventHandler.push_map_event("ping_blocked", %{
reason: "no_permission",
message: "You don't have permission to create pings on this map"
})}
end
# Catch cancel_ping failures with feedback
def handle_ui_event(
"cancel_ping",
_event,
%{assigns: %{main_character_id: nil}} = socket
) do
{:noreply, socket}
end
# Catch-all for cancel_ping to debug why it doesn't match
def handle_ui_event(
"cancel_ping",
event,
%{assigns: assigns} = socket
) do
{:noreply, socket}
end
def handle_ui_event(event, body, socket),
do: MapCoreEventHandler.handle_ui_event(event, body, socket)

View File

@@ -163,6 +163,25 @@ defmodule WandererAppWeb.MapSystemsEventHandler do
{:noreply, socket}
end
def handle_ui_event(
"layout_systems",
%{"system_ids" => system_ids},
%{
assigns: %{
map_id: map_id,
main_character_id: main_character_id,
has_tracked_characters?: true,
user_permissions: %{update_system: true}
}
} = socket
)
when not is_nil(main_character_id) do
map_id
|> WandererApp.Map.Server.layout_systems(system_ids)
{:noreply, socket}
end
def handle_ui_event(
"update_system_position",
position,

View File

@@ -58,7 +58,8 @@ defmodule WandererAppWeb.MapEventHandler do
"update_system_tag",
"update_system_temporary_name",
"update_system_status",
"manual_paste_systems_and_connections"
"manual_paste_systems_and_connections",
"layout_systems"
]
@map_system_comments_events [
@@ -339,19 +340,34 @@ defmodule WandererAppWeb.MapEventHandler do
time_status: time_status,
type: type,
ship_size_type: ship_size_type,
locked: locked
locked: locked,
custom_info: custom_info
} = _connection
),
do: %{
id: "#{solar_system_source}_#{solar_system_target}",
mass_status: mass_status,
time_status: time_status,
type: type,
ship_size_type: ship_size_type,
locked: locked,
source: "#{solar_system_source}",
target: "#{solar_system_target}"
}
) do
is_cross_list =
case custom_info do
nil ->
false
info ->
case Jason.decode(info) do
{:ok, %{"is_cross_list" => val}} -> val
_ -> false
end
end
%{
id: "#{solar_system_source}_#{solar_system_target}",
mass_status: mass_status,
time_status: time_status,
type: type,
ship_size_type: ship_size_type,
locked: locked,
source: "#{solar_system_source}",
target: "#{solar_system_target}",
is_cross_list: is_cross_list
}
end
def map_ui_system(
%{

View File

@@ -574,7 +574,8 @@ defmodule WandererAppWeb.MapsLive do
"show_temp_system_name",
"restrict_offline_showing",
"allowed_copy_for",
"allowed_paste_for"
"allowed_paste_for",
"auto_layout"
])
{:ok, updated_map} = WandererApp.MapRepo.update_options(map, options)

View File

@@ -439,55 +439,79 @@
for={@options_form}
phx-change="update_options"
>
<.input
type="select"
field={f[:layout]}
class="select h-8 min-h-[10px] !pt-1 !pb-1 text-sm bg-neutral-900"
label="Map systems layout"
placeholder="Map default layout"
options={@layout_options}
/>
<.input
type="checkbox"
field={f[:store_custom_labels]}
label="Store system custom labels"
/>
<.input
type="checkbox"
field={f[:show_temp_system_name]}
label="Allow temporary system names"
/>
<.input
type="checkbox"
field={f[:show_linked_signature_id]}
label="Show linked signature ID as custom label part"
/>
<.input
type="checkbox"
field={f[:show_linked_signature_id_temp_name]}
label="Show linked signature ID as temporary name part"
/>
<.input
type="checkbox"
field={f[:restrict_offline_showing]}
label="Show offline characters to admins & managers only"
/>
<.input
type="select"
field={f[:allowed_copy_for]}
class="select h-8 min-h-[10px] !pt-1 !pb-1 text-sm bg-neutral-900"
label="Copy map data allowed for"
placeholder="Select role to allow map data copy"
options={@allowed_copy_for_options}
/>
<.input
type="select"
field={f[:allowed_paste_for]}
class="select h-8 min-h-[10px] !pt-1 !pb-1 text-sm bg-neutral-900"
label="Paste map data allowed for"
placeholder="Select role to allow map data paste"
options={@allowed_paste_for_options}
/>
<div class="flex flex-col gap-2 p-1">
<div class="border border-dashed border-stone-600 rounded p-3">
<p class="text-xs text-stone-400 mb-2 uppercase tracking-wider font-bold">
Layout Settings
</p>
<div class="flex flex-col gap-1">
<.input
type="select"
field={f[:layout]}
class="select h-8 min-h-[10px] !pt-1 !pb-1 text-sm bg-neutral-900"
label="Map systems layout"
placeholder="Map default layout"
options={@layout_options}
/>
<.input
type="checkbox"
field={f[:auto_layout]}
label="Retrigger layout on new connection added"
/>
<.input
type="checkbox"
field={f[:show_temp_system_name]}
label="Allow hierarchical numbering (Temporary Name)"
/>
</div>
</div>
<div class="border border-dashed border-stone-600 rounded p-3 mt-2">
<p class="text-xs text-stone-400 mb-2 uppercase tracking-wider font-bold">
General Settings
</p>
<div class="flex flex-col gap-1">
<.input
type="checkbox"
field={f[:store_custom_labels]}
label="Store system custom labels"
/>
<.input
type="checkbox"
field={f[:show_linked_signature_id]}
label="Show linked signature ID as custom label part"
/>
<.input
type="checkbox"
field={f[:show_linked_signature_id_temp_name]}
label="Show linked signature ID as temporary name part"
/>
<.input
type="checkbox"
field={f[:restrict_offline_showing]}
label="Show offline characters to admins & managers only"
/>
<.input
type="select"
field={f[:allowed_copy_for]}
class="select h-8 min-h-[10px] !pt-1 !pb-1 text-sm bg-neutral-900"
label="Copy map data allowed for"
placeholder="Select role to allow map data copy"
options={@allowed_copy_for_options}
wrapper_class="mt-2"
/>
<.input
type="select"
field={f[:allowed_paste_for]}
class="select h-8 min-h-[10px] !pt-1 !pb-1 text-sm bg-neutral-900"
label="Paste map data allowed for"
placeholder="Select role to allow map data paste"
options={@allowed_paste_for_options}
wrapper_class="mt-2"
/>
</div>
</div>
</div>
</.form>
</div>

View File

@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
@source_url "https://github.com/wanderer-industries/wanderer"
@version "1.91.6"
@version "1.92.0"
def project do
[

View File

@@ -1,48 +0,0 @@
%{
title: "Event: PLEX Giveaway Announcement",
author: "Wanderer Team",
cover_image_uri: "/images/news/2025/12-18-advent-giveaway/cover.webp",
tags: ~w(event giveaway challenge christmas advent partnership),
description: "Join our Advent Christmas Giveaway Challenge! Be the fastest to claim your reward!"
}
---
![Christmas Giveaway Challenge](/images/news/2025/12-18-advent-giveaway/cover.webp "Christmas Giveaway Challenge")
### Event Details
- **Event Name:** Advent Christmas Giveaway
- **Event Link:** [Advent Christmas Giveaway](https://eventcortex.com/events/invite/cYdBywu1ygfVS3UN6ZZcmDzL1q85aDmH)
### The Season of Giving
This holiday season, we're spreading some festive cheer with a special event for our community: the **Advent Christmas Giveaway Challenge**!
---
### Tips for Participants
- **Be Ready:** Know the reveal time and be online a few minutes early.
---
### FINAL DAY
🎉 PLEX Giveaway Announcement! 🎉
Weve decided to give away 500 PLEX!
At each secret location, youll find 100 PLEX waiting for you (along with a skin 😉).
There will be a total of 5 secret locations —
see you on the spot! 🚀
Good luck, and may the fastest capsuleer win!
---
Fly safe and happy holidays,
**The Wanderer Team**
---

View File

@@ -0,0 +1,36 @@
%{
title: "Event: Weekly Giveaway Challenge",
author: "Wanderer Team",
cover_image_uri: "/images/news/2026/01-05-weekly-giveaway/cover.webp",
tags: ~w(event giveaway challenge),
description: "Join our Weekly Giveaway Challenge! Be the fastest to claim your reward!"
}
---
![Weekly Giveaway Challenge](/images/news/2026/01-05-weekly-giveaway/cover.webp "Weekly Giveaway Challenge")
### Event Details
In 2026, we're going to giveaway partnership SKIN codes for our community, every week!
- **Event Name:** Weekly Giveaway Challenge
- **Event Link:** [Join Weekly Giveaway Challenge](https://eventcortex.com/events/invite/Cjo87svZFq6J8cc1cubH4B7AR_VfPmQ4)
---
### Tips for Participants
- **Be Ready:** Know the reveal time and be online a few minutes early.
---
Good luck, and may the fastest capsuleer win!
---
Fly safe,
**Wanderer Team**
---

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,283 @@
defmodule WandererApp.Map.PositionCalculatorTest do
use ExUnit.Case, async: true
alias WandererApp.Map.PositionCalculator
test "layout_systems rearranges systems" do
systems = [
%{solar_system_id: 1, position_x: 0, position_y: 0},
%{solar_system_id: 2, position_x: 10, position_y: 10},
%{solar_system_id: 3, position_x: -10, position_y: -10}
]
connections = [
%{id: "1-2", solar_system_source: 1, solar_system_target: 2},
%{id: "1-3", solar_system_source: 1, solar_system_target: 3}
]
{updated_systems, _cross_list_ids} = PositionCalculator.layout_systems(systems, connections, [])
assert length(updated_systems) == 3
# Sort by ID to compare
updated_1 = Enum.find(updated_systems, & &1.solar_system_id == 1)
updated_2 = Enum.find(updated_systems, & &1.solar_system_id == 2)
updated_3 = Enum.find(updated_systems, & &1.solar_system_id == 3)
# Node 1 is root (layer 0)
# Node 2 and 3 are in layer 1
assert updated_1.position_x < updated_2.position_x
assert updated_1.position_x < updated_3.position_x
assert updated_2.position_x == updated_3.position_x
# Vertically centered: node 2 and 3 should be above/below each other
assert updated_2.position_y != updated_3.position_y
end
test "layout_systems prevents overlaps even with locked systems" do
systems = [
%{solar_system_id: 1, position_x: 0, position_y: 0, locked: true},
%{solar_system_id: 2, position_x: 0, position_y: 0, locked: true}, # Locked at same spot!
%{solar_system_id: 3, position_x: 100, position_y: 100}
]
connections = [
%{id: "1-3", solar_system_source: 1, solar_system_target: 3}
]
{updated_systems, _cross_list_ids} = PositionCalculator.layout_systems(systems, connections, [])
# Check for overlaps
# A system [x, x+130], [y, y+34]
for s1 <- updated_systems, s2 <- updated_systems, s1.solar_system_id < s2.solar_system_id do
assert not overlap?(s1, s2), "Systems #{s1.solar_system_id} and #{s2.solar_system_id} overlap"
end
end
defp overlap?(s1, s2) do
w = 130
h = 34
# Horizontal overlap
x_overlap = s1.position_x < s2.position_x + w and s1.position_x + w > s2.position_x
# Vertical overlap
y_overlap = s1.position_y < s2.position_y + h and s1.position_y + h > s2.position_y
x_overlap and y_overlap
end
test "layout_systems correctly handles multiple roots in a component" do
# System 1 and 2 are connected via 3, both 1 and 2 are locked (roots)
systems = [
%{solar_system_id: 1, position_x: 0, position_y: 0, locked: true, name: "A-Root"},
%{solar_system_id: 2, position_x: 0, position_y: 500, locked: true, name: "B-Root"},
%{solar_system_id: 3, position_x: 100, position_y: 100, name: "C-Node"}
]
connections = [
%{id: "1-3", solar_system_source: 1, solar_system_target: 3},
%{id: "2-3", solar_system_source: 2, solar_system_target: 3}
]
{updated_systems, _cross_list_ids} = PositionCalculator.layout_systems(systems, connections, [])
updated_1 = Enum.find(updated_systems, & &1.solar_system_id == 1)
updated_2 = Enum.find(updated_systems, & &1.solar_system_id == 2)
assert updated_1.position_y == 0
# Root 2 (B-Root) should be shifted below Root 1's subtree
assert updated_2.position_y > updated_1.position_y
end
test "layout_systems skips layout for systems involved in cross-list connections" do
# System 1 is root, connected to 3.
# System 2 is root, connected to 4.
# Connection (3, 4) is a cross-list connection.
# Systems 1, 3, 2, 4 should keep original positions because of the bridge.
systems = [
%{solar_system_id: 1, position_x: 100, position_y: 100, locked: true, name: "Root-A"},
%{solar_system_id: 2, position_x: 500, position_y: 500, locked: true, name: "Root-B"},
%{solar_system_id: 3, position_x: 200, position_y: 200, name: "Node-A3"},
%{solar_system_id: 4, position_x: 600, position_y: 600, name: "Node-B4"}
]
connections = [
%{id: "conn-1-3", solar_system_source: 1, solar_system_target: 3},
%{id: "conn-2-4", solar_system_source: 2, solar_system_target: 4},
%{id: "cross-bridge", solar_system_source: 3, solar_system_target: 4}
]
{updated_systems, cross_list_ids} = PositionCalculator.layout_systems(systems, connections, [])
# Either "cross-bridge" or "conn-2-4" (or even "conn-1-3" depending on order)
# will be detected as cross-list because roots greedily claim nodes.
assert not Enum.empty?(cross_list_ids)
for original <- systems do
updated = Enum.find(updated_systems, & &1.solar_system_id == original.solar_system_id)
assert updated.position_x == original.position_x
assert updated.position_y == original.position_y
end
end
test "layout_systems prioritizes older connections for root claims" do
# Root A connects to S (NEW).
# Root B connects to S (OLD).
# S should stay with Root B subtree.
old_time = ~U[2020-01-01 00:00:00Z]
new_time = ~U[2024-01-01 00:00:00Z]
systems = [
%{solar_system_id: 1, position_x: 0, position_y: 0, locked: true, name: "Root-Hek"},
%{solar_system_id: 2, position_x: 0, position_y: 500, locked: true, name: "Root-J220546"},
%{solar_system_id: 3, position_x: 100, position_y: 200, name: "System-S"}
]
connections = [
%{id: "hek-s", solar_system_source: 1, solar_system_target: 3, inserted_at: new_time},
%{id: "j-s", solar_system_source: 2, solar_system_target: 3, inserted_at: old_time}
]
{_updated, cross_list_ids} = PositionCalculator.layout_systems(systems, connections, [])
# "hek-s" should be the cross-list connection because "j-s" was older and claimed S first.
assert "hek-s" in cross_list_ids
end
test "layout_systems treats nil inserted_at as newer than existing connections" do
old_time = ~U[2020-01-01 00:00:00Z]
systems = [
%{solar_system_id: 1, position_x: 0, position_y: 0, locked: true, name: "Root-Hek"},
%{solar_system_id: 2, position_x: 0, position_y: 500, locked: true, name: "Root-J220546"},
%{solar_system_id: 3, position_x: 100, position_y: 200, name: "System-S"}
]
connections = [
%{id: "hek-s", solar_system_source: 1, solar_system_target: 3, inserted_at: nil},
%{id: "j-s", solar_system_source: 2, solar_system_target: 3, inserted_at: old_time}
]
{_updated, cross_list_ids} = PositionCalculator.layout_systems(systems, connections, [])
# "hek-s" with nil should be treated as new, so "j-s" (old) wins the claim for S.
# Therefore, hek-s is the cross-list connection.
assert "hek-s" in cross_list_ids
end
test "layout_systems generates hierarchical names" do
# Root (1)
# -> Child (2)
# -> Grandchild (4)
# -> Child (3)
systems = [
%{solar_system_id: 1, name: "Root", locked: true, position_x: 0, position_y: 0},
%{solar_system_id: 2, name: "A-Child", position_x: 0, position_y: 0},
%{solar_system_id: 3, name: "B-Child", position_x: 0, position_y: 0},
%{solar_system_id: 4, name: "A-Grandchild", position_x: 0, position_y: 0}
]
connections = [
%{id: "1-2", solar_system_source: 1, solar_system_target: 2, inserted_at: ~U[2020-01-01 00:00:00Z]},
%{id: "1-3", solar_system_source: 1, solar_system_target: 3, inserted_at: ~U[2020-01-01 00:00:00Z]},
%{id: "2-4", solar_system_source: 2, solar_system_target: 4, inserted_at: ~U[2020-01-01 00:00:00Z]}
]
{updated_systems, _cross_list_ids} = PositionCalculator.layout_systems(systems, connections, [])
# Sort children by name: A-Child (index 1), B-Child (index 2)
# Root -> "0"
# A-Child -> "1"
# B-Child -> "2"
# A-Grandchild -> "1-1"
s1 = Enum.find(updated_systems, & &1.solar_system_id == 1)
s2 = Enum.find(updated_systems, & &1.solar_system_id == 2)
s3 = Enum.find(updated_systems, & &1.solar_system_id == 3)
s4 = Enum.find(updated_systems, & &1.solar_system_id == 4)
assert s1.hierarchical_name == "0"
assert s2.hierarchical_name == "1"
assert s3.hierarchical_name == "2"
assert s4.hierarchical_name == "1-1"
end
test "layout_systems aligns multiple locked roots to the same X axis" do
systems = [
%{solar_system_id: 1, name: "Root-A", locked: true, position_x: 100, position_y: 100},
%{solar_system_id: 2, name: "Root-B", locked: true, position_x: 500, position_y: 500}
]
# No connections, so they are independent roots
{updated_systems, _cross_list_ids} = PositionCalculator.layout_systems(systems, [], [])
s1 = Enum.find(updated_systems, & &1.solar_system_id == 1)
s2 = Enum.find(updated_systems, & &1.solar_system_id == 2)
# Both should be forced to X = 0.0 (the root axis)
assert s1.position_x == 0
assert s2.position_x == 0
end
test "layout_systems top_to_bottom anchors roots to Y axis and arranges children vertically" do
# Root (1)
# -> Child (2)
systems = [
%{solar_system_id: 1, name: "Root", locked: true, position_x: 100, position_y: 100},
%{solar_system_id: 2, name: "Child", position_x: 0, position_y: 0}
]
connections = [
%{id: "1-2", solar_system_source: 1, solar_system_target: 2, inserted_at: ~U[2020-01-01 00:00:00Z]}
]
# Use top_to_bottom layout
{updated_systems, _cross_list_ids} = PositionCalculator.layout_systems(systems, connections, [layout: "top_to_bottom"])
s1 = Enum.find(updated_systems, & &1.solar_system_id == 1)
s2 = Enum.find(updated_systems, & &1.solar_system_id == 2)
# Root should be forced to Y = 0
assert s1.position_y == 0
# Child should be below root (Y = s1.y + @h + @m_y)
# @h = 34, @m_y = 41 -> s2.y = 0 + 34 + 41 = 75
assert s2.position_y == 75
# Since there's only one root at (0, 0), the child should have same X (0)
assert s2.position_x == 0
end
test "layout_systems detects cycles and excludes affected subtrees from layout" do
# Root (1) -> Child (2) -> Grandchild (3) -> Root (1) [Cycle]
systems = [
%{solar_system_id: 1, name: "Root", locked: true, position_x: 0, position_y: 0},
%{solar_system_id: 2, name: "Child", position_x: 500, position_y: 500},
%{solar_system_id: 3, name: "Grandchild", position_x: 1000, position_y: 1000}
]
connections = [
%{id: "1-2", solar_system_source: 1, solar_system_target: 2, inserted_at: ~U[2020-01-01 00:00:00Z]},
%{id: "2-3", solar_system_source: 2, solar_system_target: 3, inserted_at: ~U[2020-01-01 00:00:00Z]},
%{id: "3-1", solar_system_source: 3, solar_system_target: 1, inserted_at: ~U[2020-01-01 00:00:00Z]}
]
{updated_systems, special_ids} = PositionCalculator.layout_systems(systems, connections, [])
# The 3-1 connection completes the cycle and should be identified as special
assert "3-1" in special_ids
# Since the root (1) is part of a special connection, its entire subtree should be skipped
s1 = Enum.find(updated_systems, & &1.solar_system_id == 1)
s2 = Enum.find(updated_systems, & &1.solar_system_id == 2)
s3 = Enum.find(updated_systems, & &1.solar_system_id == 3)
assert s1.position_x == 0
assert s1.position_y == 0
assert s2.position_x == 500
assert s2.position_y == 500
assert s3.position_x == 1000
assert s3.position_y == 1000
# Ensure hierarchical_name is NOT added when layout is skipped due to cycle
refute Map.has_key?(s1, :hierarchical_name)
refute Map.has_key?(s2, :hierarchical_name)
refute Map.has_key?(s3, :hierarchical_name)
end
end