mirror of
https://github.com/wanderer-industries/wanderer
synced 2026-01-27 17:16:02 +00:00
Compare commits
5 Commits
develop
...
auto-layou
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
294e718b4f | ||
|
|
527c927bb8 | ||
|
|
fd04f64634 | ||
|
|
34ae21b7c3 | ||
|
|
797cda2577 |
@@ -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)
|
||||
|
||||
@@ -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 don’t 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 don’t 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]);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -41,4 +41,5 @@ export type SolarSystemConnection = {
|
||||
target: string;
|
||||
|
||||
type?: ConnectionType;
|
||||
is_cross_list?: boolean;
|
||||
};
|
||||
|
||||
@@ -281,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
12939
assets/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
1762
assets/yarn.lock
1762
assets/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -123,8 +123,7 @@ defmodule WandererApp.Api.MapSystemSignature do
|
||||
:group,
|
||||
:type,
|
||||
:custom_info,
|
||||
:deleted,
|
||||
:linked_system_id
|
||||
:deleted
|
||||
]
|
||||
end
|
||||
|
||||
@@ -141,8 +140,7 @@ defmodule WandererApp.Api.MapSystemSignature do
|
||||
:type,
|
||||
:custom_info,
|
||||
:deleted,
|
||||
:update_forced_at,
|
||||
:linked_system_id
|
||||
:update_forced_at
|
||||
]
|
||||
|
||||
primary? true
|
||||
|
||||
@@ -152,8 +152,7 @@ defmodule WandererApp.Map.Manager do
|
||||
"[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 ->
|
||||
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"
|
||||
@@ -179,10 +178,7 @@ defmodule WandererApp.Map.Manager do
|
||||
Ash.destroy!(ping)
|
||||
end)
|
||||
|
||||
Logger.info(
|
||||
"[cleanup_orphaned_pings] Cleaned up #{length(orphaned_pings)} orphaned pings"
|
||||
)
|
||||
|
||||
Logger.info("[cleanup_orphaned_pings] Cleaned up #{length(orphaned_pings)} orphaned pings")
|
||||
:ok
|
||||
|
||||
{:error, error} ->
|
||||
|
||||
@@ -126,12 +126,4 @@ defmodule WandererApp.Map.Operations do
|
||||
@doc "Delete a signature in a map"
|
||||
@spec delete_signature(String.t(), String.t()) :: :ok | {:error, String.t()}
|
||||
defdelegate delete_signature(map_id, sig_id), to: Signatures
|
||||
|
||||
@doc "Link a signature to a target system"
|
||||
@spec link_signature(Plug.Conn.t(), String.t(), map()) :: {:ok, map()} | {:error, atom()}
|
||||
defdelegate link_signature(conn, sig_id, params), to: Signatures
|
||||
|
||||
@doc "Unlink a signature from its target system"
|
||||
@spec unlink_signature(Plug.Conn.t(), String.t()) :: {:ok, map()} | {:error, atom()}
|
||||
defdelegate unlink_signature(conn, sig_id), to: Signatures
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -63,31 +63,13 @@ defmodule WandererApp.Map.Operations.Connections do
|
||||
if is_nil(src_info) or is_nil(tgt_info) do
|
||||
{:error, :invalid_system_info}
|
||||
else
|
||||
# Get wormhole_type for ship size inference
|
||||
wormhole_type = attrs["wormhole_type"]
|
||||
|
||||
# Build extra_info map with optional connection attributes
|
||||
extra_info =
|
||||
%{}
|
||||
|> maybe_add_extra("time_status", attrs["time_status"])
|
||||
|> maybe_add_extra("mass_status", attrs["mass_status"])
|
||||
|> maybe_add_extra("locked", attrs["locked"])
|
||||
|> maybe_add_extra("wormhole_type", wormhole_type)
|
||||
|
||||
info = %{
|
||||
solar_system_source_id: src_info.solar_system_id,
|
||||
solar_system_target_id: tgt_info.solar_system_id,
|
||||
character_id: char_id,
|
||||
type: parse_type(attrs["type"]),
|
||||
ship_size_type:
|
||||
resolve_ship_size(
|
||||
attrs["type"],
|
||||
attrs["ship_size_type"],
|
||||
wormhole_type,
|
||||
src_info,
|
||||
tgt_info
|
||||
),
|
||||
extra_info: if(extra_info == %{}, do: nil, else: extra_info)
|
||||
resolve_ship_size(attrs["type"], attrs["ship_size_type"], src_info, tgt_info)
|
||||
}
|
||||
|
||||
case Server.add_connection(map_id, info) do
|
||||
@@ -113,11 +95,10 @@ defmodule WandererApp.Map.Operations.Connections do
|
||||
|
||||
# Determines the ship size for a connection, applying wormhole-specific rules
|
||||
# for C1, C13, and C4⇄NS links, falling back to the caller's provided size or Large.
|
||||
# If wormhole_type is provided (e.g., "H296"), infer ship size from it.
|
||||
defp resolve_ship_size(type_val, ship_size_val, wormhole_type, src_info, tgt_info) do
|
||||
defp resolve_ship_size(type_val, ship_size_val, src_info, tgt_info) do
|
||||
case parse_type(type_val) do
|
||||
@connection_type_wormhole ->
|
||||
wormhole_ship_size(ship_size_val, wormhole_type, src_info, tgt_info)
|
||||
wormhole_ship_size(ship_size_val, src_info, tgt_info)
|
||||
|
||||
_other ->
|
||||
# Stargates and others just use the parsed or default size
|
||||
@@ -127,45 +108,15 @@ defmodule WandererApp.Map.Operations.Connections do
|
||||
|
||||
# -- Wormhole‑specific sizing rules ----------------------------------------
|
||||
|
||||
defp wormhole_ship_size(ship_size_val, wormhole_type, src, tgt) do
|
||||
# First, try to infer from wormhole_type (e.g., "H296", "C5", etc.)
|
||||
inferred_size = infer_ship_size_from_wormhole_type(wormhole_type)
|
||||
# Parse ship_size_val early to handle string values correctly
|
||||
parsed_ship_size = parse_ship_size(ship_size_val, nil)
|
||||
|
||||
defp wormhole_ship_size(ship_size_val, src, tgt) do
|
||||
cond do
|
||||
# If user explicitly provided a ship_size_val, use it
|
||||
not is_nil(parsed_ship_size) ->
|
||||
parsed_ship_size
|
||||
|
||||
# If we could infer from wormhole_type, use that
|
||||
not is_nil(inferred_size) ->
|
||||
inferred_size
|
||||
|
||||
# Otherwise fall back to system class rules
|
||||
c1_system?(src, tgt) ->
|
||||
@medium_ship_size
|
||||
|
||||
c13_system?(src, tgt) ->
|
||||
@small_ship_size
|
||||
|
||||
c4_to_ns?(src, tgt) ->
|
||||
@small_ship_size
|
||||
|
||||
true ->
|
||||
@large_ship_size
|
||||
c1_system?(src, tgt) -> @medium_ship_size
|
||||
c13_system?(src, tgt) -> @small_ship_size
|
||||
c4_to_ns?(src, tgt) -> @small_ship_size
|
||||
true -> parse_ship_size(ship_size_val, @large_ship_size)
|
||||
end
|
||||
end
|
||||
|
||||
# Infer ship size from wormhole type name using EVE static data
|
||||
defp infer_ship_size_from_wormhole_type(nil), do: nil
|
||||
defp infer_ship_size_from_wormhole_type(""), do: nil
|
||||
defp infer_ship_size_from_wormhole_type("K162"), do: nil
|
||||
|
||||
defp infer_ship_size_from_wormhole_type(wormhole_type) do
|
||||
WandererApp.Utils.EVEUtil.get_wh_size(wormhole_type)
|
||||
end
|
||||
|
||||
defp c1_system?(%{system_class: @c1_system_class}, _), do: true
|
||||
defp c1_system?(_, %{system_class: @c1_system_class}), do: true
|
||||
defp c1_system?(_, _), do: false
|
||||
@@ -211,9 +162,6 @@ defmodule WandererApp.Map.Operations.Connections do
|
||||
|
||||
defp parse_type(_), do: @connection_type_wormhole
|
||||
|
||||
defp maybe_add_extra(map, _key, nil), do: map
|
||||
defp maybe_add_extra(map, key, value), do: Map.put(map, key, value)
|
||||
|
||||
defp parse_int(nil, field), do: {:error, {:missing_field, field}}
|
||||
defp parse_int(val, _) when is_integer(val), do: {:ok, val}
|
||||
|
||||
|
||||
@@ -5,10 +5,8 @@ defmodule WandererApp.Map.Operations.Signatures do
|
||||
|
||||
require Logger
|
||||
alias WandererApp.Map.Operations
|
||||
alias WandererApp.Map.Operations.Connections
|
||||
alias WandererApp.Api.{Character, MapSystem, MapSystemSignature}
|
||||
alias WandererApp.Map.Server
|
||||
alias WandererApp.Utils.EVEUtil
|
||||
|
||||
@spec validate_character_eve_id(map() | nil, String.t()) ::
|
||||
{:ok, String.t()} | {:error, :invalid_character} | {:error, :unexpected_error}
|
||||
@@ -80,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} <- ensure_system_on_map(map_id, solar_system_id, user_id, char_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)
|
||||
@@ -96,21 +95,6 @@ defmodule WandererApp.Map.Operations.Signatures do
|
||||
delete_connection_with_sigs: false
|
||||
}) do
|
||||
:ok ->
|
||||
# Handle linked_system_id if provided - auto-add system and create/update connection
|
||||
linked_system_id = Map.get(params, "linked_system_id")
|
||||
wormhole_type = Map.get(params, "type")
|
||||
|
||||
if is_integer(linked_system_id) and linked_system_id != solar_system_id do
|
||||
handle_linked_system(
|
||||
map_id,
|
||||
solar_system_id,
|
||||
linked_system_id,
|
||||
wormhole_type,
|
||||
user_id,
|
||||
char_id
|
||||
)
|
||||
end
|
||||
|
||||
# Try to fetch the created signature to return with proper fields
|
||||
with {:ok, sigs} <-
|
||||
MapSystemSignature.by_system_id_and_eve_ids(system.id, [attrs["eve_id"]]),
|
||||
@@ -146,13 +130,6 @@ defmodule WandererApp.Map.Operations.Signatures do
|
||||
Logger.error("[create_signature] Unexpected error during character validation")
|
||||
{:error, :unexpected_error}
|
||||
|
||||
{:error, :invalid_solar_system} ->
|
||||
Logger.error(
|
||||
"[create_signature] Invalid solar_system_id: #{solar_system_id} (not a valid EVE system)"
|
||||
)
|
||||
|
||||
{:error, :invalid_solar_system}
|
||||
|
||||
_ ->
|
||||
Logger.error(
|
||||
"[create_signature] System not found for solar_system_id: #{solar_system_id}"
|
||||
@@ -171,203 +148,6 @@ defmodule WandererApp.Map.Operations.Signatures do
|
||||
|
||||
def create_signature(_conn, _params), do: {:error, :missing_params}
|
||||
|
||||
# Check cache (not DB) to ensure system is actually visible on the map.
|
||||
@spec ensure_system_on_map(String.t(), integer(), String.t(), String.t()) ::
|
||||
{:ok, map()} | {:error, atom()}
|
||||
defp ensure_system_on_map(map_id, solar_system_id, user_id, char_id) do
|
||||
case WandererApp.Map.find_system_by_location(map_id, %{solar_system_id: solar_system_id}) do
|
||||
nil -> add_system_to_map(map_id, solar_system_id, user_id, char_id)
|
||||
system -> {:ok, system}
|
||||
end
|
||||
end
|
||||
|
||||
@spec add_system_to_map(String.t(), integer(), String.t(), String.t()) ::
|
||||
{:ok, map()} | {:error, atom()}
|
||||
defp add_system_to_map(map_id, solar_system_id, user_id, char_id) do
|
||||
with {:ok, static_info} when not is_nil(static_info) <-
|
||||
WandererApp.CachedInfo.get_system_static_info(solar_system_id),
|
||||
:ok <-
|
||||
Server.add_system(
|
||||
map_id,
|
||||
%{solar_system_id: solar_system_id, coordinates: nil},
|
||||
user_id,
|
||||
char_id
|
||||
),
|
||||
system when not is_nil(system) <- fetch_system_after_add(map_id, solar_system_id) do
|
||||
Logger.info("[create_signature] Auto-added system #{solar_system_id} to map #{map_id}")
|
||||
{:ok, system}
|
||||
else
|
||||
{:ok, nil} ->
|
||||
{:error, :invalid_solar_system}
|
||||
|
||||
{:error, _} ->
|
||||
{:error, :invalid_solar_system}
|
||||
|
||||
nil ->
|
||||
Logger.error("[add_system_to_map] Failed to fetch system after add")
|
||||
{:error, :system_add_failed}
|
||||
|
||||
error ->
|
||||
Logger.error("[add_system_to_map] Failed to add system: #{inspect(error)}")
|
||||
{:error, :system_add_failed}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_system_after_add(map_id, solar_system_id) do
|
||||
case WandererApp.Map.find_system_by_location(map_id, %{solar_system_id: solar_system_id}) do
|
||||
nil ->
|
||||
case MapSystem.read_by_map_and_solar_system(%{
|
||||
map_id: map_id,
|
||||
solar_system_id: solar_system_id
|
||||
}) do
|
||||
{:ok, system} -> system
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
system ->
|
||||
system
|
||||
end
|
||||
end
|
||||
|
||||
# Handles the linked_system_id logic: auto-adds the linked system and creates/updates connection
|
||||
@spec handle_linked_system(
|
||||
String.t(),
|
||||
integer(),
|
||||
integer(),
|
||||
String.t() | nil,
|
||||
String.t(),
|
||||
String.t()
|
||||
) :: :ok | {:error, atom()}
|
||||
defp handle_linked_system(
|
||||
map_id,
|
||||
source_system_id,
|
||||
linked_system_id,
|
||||
wormhole_type,
|
||||
user_id,
|
||||
char_id
|
||||
) do
|
||||
# Ensure the linked system is on the map
|
||||
case ensure_system_on_map(map_id, linked_system_id, user_id, char_id) do
|
||||
{:ok, _linked_system} ->
|
||||
# Check if connection exists between the systems
|
||||
case Connections.get_connection_by_systems(map_id, source_system_id, linked_system_id) do
|
||||
{:ok, nil} ->
|
||||
# No connection exists, create one
|
||||
create_connection_with_wormhole_type(
|
||||
map_id,
|
||||
source_system_id,
|
||||
linked_system_id,
|
||||
wormhole_type,
|
||||
char_id
|
||||
)
|
||||
|
||||
{:ok, _existing_conn} ->
|
||||
# Connection exists, update wormhole type if provided
|
||||
update_connection_wormhole_type(
|
||||
map_id,
|
||||
source_system_id,
|
||||
linked_system_id,
|
||||
wormhole_type
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"[handle_linked_system] Failed to check connection: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
{:error, :connection_check_failed}
|
||||
end
|
||||
|
||||
{:error, :invalid_solar_system} ->
|
||||
Logger.warning(
|
||||
"[handle_linked_system] Invalid linked_system_id: #{linked_system_id} (not a valid EVE system)"
|
||||
)
|
||||
|
||||
{:error, :invalid_linked_system}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("[handle_linked_system] Failed to add linked system: #{inspect(reason)}")
|
||||
{:error, :linked_system_add_failed}
|
||||
end
|
||||
end
|
||||
|
||||
# Creates a connection between two systems with the specified wormhole type
|
||||
@spec create_connection_with_wormhole_type(
|
||||
String.t(),
|
||||
integer(),
|
||||
integer(),
|
||||
String.t() | nil,
|
||||
String.t()
|
||||
) :: :ok | {:error, atom()}
|
||||
defp create_connection_with_wormhole_type(
|
||||
map_id,
|
||||
source_system_id,
|
||||
target_system_id,
|
||||
wormhole_type,
|
||||
char_id
|
||||
) do
|
||||
conn_attrs = %{
|
||||
"solar_system_source" => source_system_id,
|
||||
"solar_system_target" => target_system_id,
|
||||
"type" => 0,
|
||||
"wormhole_type" => wormhole_type
|
||||
}
|
||||
|
||||
case Connections.create(conn_attrs, map_id, char_id) do
|
||||
{:ok, :created} ->
|
||||
Logger.info(
|
||||
"[create_signature] Auto-created connection #{source_system_id} <-> #{target_system_id} (type: #{wormhole_type || "unknown"})"
|
||||
)
|
||||
|
||||
:ok
|
||||
|
||||
{:skip, :exists} ->
|
||||
# Connection already exists (race condition), update it instead
|
||||
update_connection_wormhole_type(map_id, source_system_id, target_system_id, wormhole_type)
|
||||
|
||||
error ->
|
||||
Logger.warning(
|
||||
"[create_connection_with_wormhole_type] Failed to create connection: #{inspect(error)}"
|
||||
)
|
||||
|
||||
{:error, :connection_create_failed}
|
||||
end
|
||||
end
|
||||
|
||||
# Updates the wormhole type and ship size for an existing connection
|
||||
@spec update_connection_wormhole_type(String.t(), integer(), integer(), String.t() | nil) ::
|
||||
:ok | {:error, atom()}
|
||||
defp update_connection_wormhole_type(_map_id, _source, _target, nil), do: :ok
|
||||
|
||||
defp update_connection_wormhole_type(map_id, source_system_id, target_system_id, wormhole_type) do
|
||||
# Get ship size from wormhole type
|
||||
ship_size_type = EVEUtil.get_wh_size(wormhole_type)
|
||||
|
||||
if not is_nil(ship_size_type) do
|
||||
case Server.update_connection_ship_size_type(map_id, %{
|
||||
solar_system_source_id: source_system_id,
|
||||
solar_system_target_id: target_system_id,
|
||||
ship_size_type: ship_size_type
|
||||
}) do
|
||||
:ok ->
|
||||
Logger.info(
|
||||
"[create_signature] Updated connection #{source_system_id} <-> #{target_system_id} ship_size_type to #{ship_size_type} (wormhole: #{wormhole_type})"
|
||||
)
|
||||
|
||||
:ok
|
||||
|
||||
error ->
|
||||
Logger.warning(
|
||||
"[update_connection_wormhole_type] Failed to update ship size: #{inspect(error)}"
|
||||
)
|
||||
|
||||
{:error, :ship_size_update_failed}
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@spec update_signature(Plug.Conn.t(), String.t(), map()) :: {:ok, map()} | {:error, atom()}
|
||||
def update_signature(
|
||||
%{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} =
|
||||
@@ -469,161 +249,4 @@ defmodule WandererApp.Map.Operations.Signatures do
|
||||
end
|
||||
|
||||
def delete_signature(_conn, _sig_id), do: {:error, :missing_params}
|
||||
|
||||
@doc """
|
||||
Links a signature to a target system, creating the association between
|
||||
the signature and the wormhole connection to that system.
|
||||
|
||||
This also:
|
||||
- Updates the signature's group to "Wormhole"
|
||||
- Sets the target system's linked_sig_eve_id
|
||||
- Copies temporary_name from signature to target system
|
||||
- Updates connection time_status and ship_size_type from signature data
|
||||
"""
|
||||
@spec link_signature(Plug.Conn.t(), String.t(), map()) :: {:ok, map()} | {:error, atom()}
|
||||
def link_signature(
|
||||
%{assigns: %{map_id: map_id}} = _conn,
|
||||
sig_id,
|
||||
%{"solar_system_target" => solar_system_target}
|
||||
)
|
||||
when is_integer(solar_system_target) do
|
||||
with {:ok, signature} <- MapSystemSignature.by_id(sig_id),
|
||||
{:ok, source_system} <- MapSystem.by_id(signature.system_id),
|
||||
true <- source_system.map_id == map_id,
|
||||
target_system when not is_nil(target_system) <-
|
||||
WandererApp.Map.find_system_by_location(map_id, %{solar_system_id: solar_system_target}) do
|
||||
# Update signature group to Wormhole and set linked_system_id
|
||||
{:ok, updated_signature} =
|
||||
signature
|
||||
|> MapSystemSignature.update_group!(%{group: "Wormhole"})
|
||||
|> MapSystemSignature.update_linked_system(%{linked_system_id: solar_system_target})
|
||||
|
||||
# Only update target system if it doesn't already have a linked signature
|
||||
if is_nil(target_system.linked_sig_eve_id) do
|
||||
# Set the target system's linked_sig_eve_id
|
||||
Server.update_system_linked_sig_eve_id(map_id, %{
|
||||
solar_system_id: solar_system_target,
|
||||
linked_sig_eve_id: signature.eve_id
|
||||
})
|
||||
|
||||
# Copy temporary_name if present
|
||||
if not is_nil(signature.temporary_name) do
|
||||
Server.update_system_temporary_name(map_id, %{
|
||||
solar_system_id: solar_system_target,
|
||||
temporary_name: signature.temporary_name
|
||||
})
|
||||
end
|
||||
|
||||
# Update connection time_status from signature custom_info
|
||||
signature_time_status =
|
||||
if not is_nil(signature.custom_info) do
|
||||
case Jason.decode(signature.custom_info) do
|
||||
{:ok, map} -> Map.get(map, "time_status")
|
||||
{:error, _} -> nil
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
if not is_nil(signature_time_status) do
|
||||
Server.update_connection_time_status(map_id, %{
|
||||
solar_system_source_id: source_system.solar_system_id,
|
||||
solar_system_target_id: solar_system_target,
|
||||
time_status: signature_time_status
|
||||
})
|
||||
end
|
||||
|
||||
# Update connection ship_size_type from signature wormhole type
|
||||
signature_ship_size_type = EVEUtil.get_wh_size(signature.type)
|
||||
|
||||
if not is_nil(signature_ship_size_type) do
|
||||
Server.update_connection_ship_size_type(map_id, %{
|
||||
solar_system_source_id: source_system.solar_system_id,
|
||||
solar_system_target_id: solar_system_target,
|
||||
ship_size_type: signature_ship_size_type
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
# Broadcast update
|
||||
Server.Impl.broadcast!(map_id, :signatures_updated, source_system.solar_system_id)
|
||||
|
||||
# Return the updated signature
|
||||
result =
|
||||
updated_signature
|
||||
|> Map.from_struct()
|
||||
|> Map.put(:solar_system_id, source_system.solar_system_id)
|
||||
|> Map.drop([:system_id, :__meta__, :system, :aggregates, :calculations])
|
||||
|
||||
{:ok, result}
|
||||
else
|
||||
false ->
|
||||
{:error, :not_found}
|
||||
|
||||
nil ->
|
||||
{:error, :target_system_not_found}
|
||||
|
||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||
{:error, :not_found}
|
||||
|
||||
err ->
|
||||
Logger.error("[link_signature] Unexpected error: #{inspect(err)}")
|
||||
{:error, :unexpected_error}
|
||||
end
|
||||
end
|
||||
|
||||
def link_signature(_conn, _sig_id, %{"solar_system_target" => _}),
|
||||
do: {:error, :invalid_solar_system_target}
|
||||
|
||||
def link_signature(_conn, _sig_id, _params), do: {:error, :missing_params}
|
||||
|
||||
@doc """
|
||||
Unlinks a signature from its target system.
|
||||
"""
|
||||
@spec unlink_signature(Plug.Conn.t(), String.t()) :: {:ok, map()} | {:error, atom()}
|
||||
def unlink_signature(%{assigns: %{map_id: map_id}} = _conn, sig_id) do
|
||||
with {:ok, signature} <- MapSystemSignature.by_id(sig_id),
|
||||
{:ok, source_system} <- MapSystem.by_id(signature.system_id),
|
||||
:ok <- if(source_system.map_id == map_id, do: :ok, else: {:error, :not_found}),
|
||||
:ok <- if(not is_nil(signature.linked_system_id), do: :ok, else: {:error, :not_linked}) do
|
||||
# Clear the target system's linked_sig_eve_id
|
||||
Server.update_system_linked_sig_eve_id(map_id, %{
|
||||
solar_system_id: signature.linked_system_id,
|
||||
linked_sig_eve_id: nil
|
||||
})
|
||||
|
||||
# Clear the signature's linked_system_id using the wrapper for logging
|
||||
{:ok, updated_signature} =
|
||||
Server.SignaturesImpl.update_signature_linked_system(signature, %{
|
||||
linked_system_id: nil
|
||||
})
|
||||
|
||||
# Broadcast update
|
||||
Server.Impl.broadcast!(map_id, :signatures_updated, source_system.solar_system_id)
|
||||
|
||||
# Return the updated signature
|
||||
result =
|
||||
updated_signature
|
||||
|> Map.from_struct()
|
||||
|> Map.put(:solar_system_id, source_system.solar_system_id)
|
||||
|> Map.drop([:system_id, :__meta__, :system, :aggregates, :calculations])
|
||||
|
||||
{:ok, result}
|
||||
else
|
||||
{:error, :not_found} ->
|
||||
{:error, :not_found}
|
||||
|
||||
{:error, :not_linked} ->
|
||||
{:error, :not_linked}
|
||||
|
||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||
{:error, :not_found}
|
||||
|
||||
err ->
|
||||
Logger.error("[unlink_signature] Unexpected error: #{inspect(err)}")
|
||||
{:error, :unexpected_error}
|
||||
end
|
||||
end
|
||||
|
||||
def unlink_signature(_conn, _sig_id), do: {:error, :missing_params}
|
||||
end
|
||||
|
||||
@@ -36,8 +36,7 @@ defmodule WandererApp.Map.Operations.Systems do
|
||||
# Private helper for batch upsert
|
||||
defp create_system_batch(%{map_id: map_id, user_id: user_id, char_id: char_id}, params) do
|
||||
with {:ok, solar_system_id} <- fetch_system_id(params) do
|
||||
# Default to true so re-submitting with new position updates the system
|
||||
update_existing = fetch_update_existing(params, true)
|
||||
update_existing = fetch_update_existing(params, false)
|
||||
|
||||
map_id
|
||||
|> WandererApp.Map.check_location(%{solar_system_id: solar_system_id})
|
||||
@@ -47,13 +46,9 @@ defmodule WandererApp.Map.Operations.Systems do
|
||||
|
||||
{:error, :already_exists} ->
|
||||
if update_existing do
|
||||
# Mark as skip so it counts as "updated" not "created"
|
||||
case do_update_system(map_id, user_id, char_id, solar_system_id, params) do
|
||||
{:ok, _} -> {:skip, :updated}
|
||||
error -> error
|
||||
end
|
||||
do_update_system(map_id, user_id, char_id, solar_system_id, params)
|
||||
else
|
||||
{:skip, :already_exists}
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -205,22 +200,16 @@ defmodule WandererApp.Map.Operations.Systems do
|
||||
|
||||
defp normalize_coordinates(%{"coordinates" => %{"x" => x, "y" => y}})
|
||||
when is_number(x) and is_number(y),
|
||||
do: %{"x" => x, "y" => y}
|
||||
do: %{x: x, y: y}
|
||||
|
||||
defp normalize_coordinates(%{coordinates: %{x: x, y: y}}) when is_number(x) and is_number(y),
|
||||
do: %{"x" => x, "y" => y}
|
||||
do: %{x: x, y: y}
|
||||
|
||||
defp normalize_coordinates(params) do
|
||||
x = params |> Map.get("position_x", Map.get(params, :position_x))
|
||||
y = params |> Map.get("position_y", Map.get(params, :position_y))
|
||||
|
||||
# Only return coordinates if both x and y are provided
|
||||
# Otherwise return nil to let the server use auto-positioning
|
||||
if is_number(x) and is_number(y) do
|
||||
%{"x" => x, "y" => y}
|
||||
else
|
||||
nil
|
||||
end
|
||||
%{
|
||||
x: params |> Map.get("position_x", Map.get(params, :position_x, 0)),
|
||||
y: params |> Map.get("position_y", Map.get(params, :position_y, 0))
|
||||
}
|
||||
end
|
||||
|
||||
defp apply_system_updates(map_id, system_id, attrs, %{x: x, y: y}) do
|
||||
|
||||
@@ -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
|
||||
@@ -595,7 +615,6 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
|
||||
time_status = get_extra_info(extra_info, "time_status", time_status)
|
||||
mass_status = get_extra_info(extra_info, "mass_status", 0)
|
||||
locked = get_extra_info(extra_info, "locked", false)
|
||||
wormhole_type = get_extra_info(extra_info, "wormhole_type", nil)
|
||||
|
||||
{:ok, connection} =
|
||||
WandererApp.MapConnectionRepo.create(%{
|
||||
@@ -606,8 +625,7 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
|
||||
ship_size_type: ship_size_type,
|
||||
time_status: time_status,
|
||||
mass_status: mass_status,
|
||||
locked: locked,
|
||||
wormhole_type: wormhole_type
|
||||
locked: locked
|
||||
})
|
||||
|
||||
if connection_type == @connection_type_wormhole do
|
||||
@@ -648,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} ->
|
||||
@@ -672,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
|
||||
|
||||
@@ -917,10 +943,8 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
|
||||
if not from_is_wormhole and not to_is_wormhole do
|
||||
# Check if there's a known stargate
|
||||
case find_solar_system_jump(from_solar_system_id, to_solar_system_id) do
|
||||
# No stargate = wormhole connection
|
||||
{:ok, []} -> true
|
||||
# Stargate exists or error
|
||||
_ -> false
|
||||
{:ok, []} -> true # No stargate = wormhole connection
|
||||
_ -> false # Stargate exists or error
|
||||
end
|
||||
else
|
||||
false
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ defmodule WandererApp.Map.Server.PingsImpl do
|
||||
type: type
|
||||
} = _ping_info
|
||||
) do
|
||||
|
||||
result = WandererApp.MapPingsRepo.get_by_id(ping_id)
|
||||
|
||||
case result do
|
||||
|
||||
@@ -109,10 +109,8 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
|
||||
nil ->
|
||||
MapSystemSignature.create!(sig)
|
||||
|
||||
existing ->
|
||||
# If signature already exists, update it instead of ignoring
|
||||
# This handles the case where frontend sends existing sigs as "added"
|
||||
apply_update_signature(map_id, existing, sig)
|
||||
_ ->
|
||||
:noop
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -329,7 +327,6 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
|
||||
group: sig["group"],
|
||||
type: Map.get(sig, "type"),
|
||||
custom_info: Map.get(sig, "custom_info"),
|
||||
linked_system_id: Map.get(sig, "linked_system_id"),
|
||||
# Use character_eve_id from sig if provided, otherwise use the default
|
||||
character_eve_id: Map.get(sig, "character_eve_id", character_eve_id),
|
||||
deleted: false
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,18 +41,14 @@
|
||||
<div class="absolute rounded-m top-0 left-0 w-full h-full bg-gradient-to-b from-transparent to-black opacity-75 group-hover:opacity-25 transition-opacity duration-300">
|
||||
</div>
|
||||
<div class="absolute w-full bottom-2 p-4">
|
||||
<% {first_part, second_part} =
|
||||
case String.split(post.title, ":", parts: 2) do
|
||||
[first, second] -> {first, second}
|
||||
[first] -> {first, nil}
|
||||
end %>
|
||||
<% {first_part, second_part} = case String.split(post.title, ":", parts: 2) do
|
||||
[first, second] -> {first, second}
|
||||
[first] -> {first, nil}
|
||||
end %>
|
||||
<h3 class="!m-0 !text-s font-bold break-normal ccp-font whitespace-nowrap text-white">
|
||||
{first_part}
|
||||
</h3>
|
||||
<p
|
||||
:if={second_part}
|
||||
class="!m-0 !text-s text-white text-ellipsis overflow-hidden whitespace-nowrap ccp-font"
|
||||
>
|
||||
<p :if={second_part} class="!m-0 !text-s text-white text-ellipsis overflow-hidden whitespace-nowrap ccp-font">
|
||||
{second_part}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -487,17 +487,10 @@ defmodule WandererAppWeb.MapSystemAPIController do
|
||||
)
|
||||
|
||||
def create(conn, params) do
|
||||
# Support multiple formats:
|
||||
# 1. Batch format: {"systems": [...], "connections": [...]}
|
||||
# 2. Wrapped batch format: {"data": {"systems": [...], "connections": [...]}}
|
||||
# 3. Single system format: {"solar_system_id": ..., ...}
|
||||
# Support both batch format {"systems": [...], "connections": [...]}
|
||||
# and single system format {"solar_system_id": ..., ...}
|
||||
{systems, connections} =
|
||||
cond do
|
||||
Map.has_key?(params, "data") and is_map(params["data"]) ->
|
||||
# Wrapped batch format - extract from data wrapper
|
||||
data = params["data"]
|
||||
{Map.get(data, "systems", []), Map.get(data, "connections", [])}
|
||||
|
||||
Map.has_key?(params, "systems") ->
|
||||
# Batch format
|
||||
{Map.get(params, "systems", []), Map.get(params, "connections", [])}
|
||||
|
||||
@@ -190,37 +190,9 @@ defmodule WandererAppWeb.MapSystemSignatureAPIController do
|
||||
The `character_eve_id` field is optional. If provided, it must be a valid character
|
||||
that exists in the database, otherwise a 422 error will be returned. If not provided,
|
||||
the signature will be associated with the map owner's character.
|
||||
|
||||
## Auto-add System Behavior
|
||||
|
||||
If the `solar_system_id` is not already on the map, it will be automatically added.
|
||||
The system must be a valid EVE Online solar system ID.
|
||||
|
||||
## Linked System and Connection Behavior
|
||||
|
||||
If `linked_system_id` is provided (for wormhole signatures):
|
||||
- The linked system will be automatically added to the map if not present
|
||||
- A connection will be created between the source and linked systems if one doesn't exist
|
||||
- If a connection already exists, its ship size will be updated based on the wormhole `type`
|
||||
- The wormhole `type` (e.g., "H296", "C2", "K162") is used to determine connection ship size:
|
||||
- H296 → XL/Freighter size (1B kg max mass)
|
||||
- N770, D845 → Large size (375M kg max mass)
|
||||
- etc.
|
||||
"""
|
||||
operation(:create,
|
||||
summary: "Create a new signature",
|
||||
description: """
|
||||
Creates a new cosmic signature in the specified solar system.
|
||||
|
||||
**Auto-add behavior**: If the solar_system_id is not already on the map, it will be
|
||||
automatically added. The system must be a valid EVE Online solar system ID.
|
||||
|
||||
**Linked system behavior**: If linked_system_id is provided:
|
||||
- The linked system is auto-added to the map if not present
|
||||
- A wormhole connection is auto-created between the systems
|
||||
- The connection's ship_size_type is inferred from the wormhole type (e.g., H296 → XL)
|
||||
- If the connection already exists, its ship size is updated based on the wormhole type
|
||||
""",
|
||||
parameters: [
|
||||
map_identifier: [
|
||||
in: :path,
|
||||
@@ -246,7 +218,7 @@ defmodule WandererAppWeb.MapSystemSignatureAPIController do
|
||||
error: %OpenApiSpex.Schema{
|
||||
type: :string,
|
||||
description:
|
||||
"Error type (e.g., 'invalid_character', 'invalid_solar_system', 'missing_params')"
|
||||
"Error type (e.g., 'invalid_character', 'system_not_found', 'missing_params')"
|
||||
}
|
||||
},
|
||||
example: %{error: "invalid_character"}
|
||||
@@ -339,117 +311,4 @@ defmodule WandererAppWeb.MapSystemSignatureAPIController do
|
||||
{:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Link a signature to a target system.
|
||||
|
||||
This creates the association between a wormhole signature and the system it leads to.
|
||||
It also updates the connection's time_status and ship_size_type based on the signature data.
|
||||
"""
|
||||
operation(:link,
|
||||
summary: "Link a signature to a target system",
|
||||
description: """
|
||||
Links a wormhole signature to its destination system. This operation:
|
||||
- Sets the signature's linked_system_id to the target system
|
||||
- Updates the signature's group to "Wormhole"
|
||||
- Sets the target system's linked_sig_eve_id (if not already set)
|
||||
- Copies temporary_name from signature to target system
|
||||
- Updates the connection's time_status and ship_size_type from signature data
|
||||
""",
|
||||
parameters: [
|
||||
map_identifier: [
|
||||
in: :path,
|
||||
description: "Map identifier (UUID or slug)",
|
||||
type: :string,
|
||||
required: true
|
||||
],
|
||||
id: [in: :path, description: "Signature UUID", type: :string, required: true]
|
||||
],
|
||||
request_body:
|
||||
{"Link request", "application/json",
|
||||
%OpenApiSpex.Schema{
|
||||
type: :object,
|
||||
properties: %{
|
||||
solar_system_target: %OpenApiSpex.Schema{
|
||||
type: :integer,
|
||||
description: "Target solar system ID to link to"
|
||||
}
|
||||
},
|
||||
required: [:solar_system_target],
|
||||
example: %{solar_system_target: 31_001_922}
|
||||
}},
|
||||
responses: [
|
||||
ok:
|
||||
{"Linked signature", "application/json",
|
||||
%OpenApiSpex.Schema{
|
||||
type: :object,
|
||||
properties: %{data: @signature_schema},
|
||||
example: %{data: @signature_schema.example}
|
||||
}},
|
||||
unprocessable_entity:
|
||||
{"Error", "application/json",
|
||||
%OpenApiSpex.Schema{
|
||||
type: :object,
|
||||
properties: %{
|
||||
error: %OpenApiSpex.Schema{
|
||||
type: :string,
|
||||
description: "Error type"
|
||||
}
|
||||
},
|
||||
example: %{error: "target_system_not_found"}
|
||||
}}
|
||||
]
|
||||
)
|
||||
|
||||
def link(conn, %{"id" => id} = params) do
|
||||
case MapOperations.link_signature(conn, id, params) do
|
||||
{:ok, sig} -> json(conn, %{data: sig})
|
||||
{:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Unlink a signature from its target system.
|
||||
"""
|
||||
operation(:unlink,
|
||||
summary: "Unlink a signature from its target system",
|
||||
description: "Removes the link between a signature and its destination system.",
|
||||
parameters: [
|
||||
map_identifier: [
|
||||
in: :path,
|
||||
description: "Map identifier (UUID or slug)",
|
||||
type: :string,
|
||||
required: true
|
||||
],
|
||||
id: [in: :path, description: "Signature UUID", type: :string, required: true]
|
||||
],
|
||||
responses: [
|
||||
ok:
|
||||
{"Unlinked signature", "application/json",
|
||||
%OpenApiSpex.Schema{
|
||||
type: :object,
|
||||
properties: %{data: @signature_schema},
|
||||
example: %{data: Map.put(@signature_schema.example, :linked_system_id, nil)}
|
||||
}},
|
||||
unprocessable_entity:
|
||||
{"Error", "application/json",
|
||||
%OpenApiSpex.Schema{
|
||||
type: :object,
|
||||
properties: %{
|
||||
error: %OpenApiSpex.Schema{
|
||||
type: :string,
|
||||
description: "Error type"
|
||||
}
|
||||
},
|
||||
example: %{error: "not_linked"}
|
||||
}}
|
||||
]
|
||||
)
|
||||
|
||||
def unlink(conn, %{"id" => id}) do
|
||||
case MapOperations.unlink_signature(conn, id) do
|
||||
{:ok, sig} -> json(conn, %{data: sig})
|
||||
{:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -34,9 +34,7 @@
|
||||
<.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>
|
||||
<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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
%{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -299,8 +299,6 @@ defmodule WandererAppWeb.Router do
|
||||
resources "/structures", MapSystemStructureAPIController, except: [:new, :edit]
|
||||
get "/structure-timers", MapSystemStructureAPIController, :structure_timers
|
||||
resources "/signatures", MapSystemSignatureAPIController, except: [:new, :edit]
|
||||
post "/signatures/:id/link", MapSystemSignatureAPIController, :link
|
||||
delete "/signatures/:id/link", MapSystemSignatureAPIController, :unlink
|
||||
get "/user-characters", MapAPIController, :show_user_characters
|
||||
get "/tracked-characters", MapAPIController, :show_tracked_characters
|
||||
end
|
||||
|
||||
@@ -26,9 +26,9 @@ defmodule WandererApp.Map.CorporationChangePermissionTest do
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
@test_corp_id_a 98_000_001
|
||||
@test_corp_id_b 98_000_002
|
||||
@test_alliance_id_a 99_000_001
|
||||
@test_corp_id_a 98000001
|
||||
@test_corp_id_b 98000002
|
||||
@test_alliance_id_a 99000001
|
||||
|
||||
setup do
|
||||
# Configure the PubSubMock to forward to real Phoenix.PubSub for broadcast testing
|
||||
@@ -70,8 +70,7 @@ defmodule WandererApp.Map.CorporationChangePermissionTest do
|
||||
simulate_corporation_change(character, @test_corp_id_b)
|
||||
|
||||
# Should receive :update_permissions broadcast
|
||||
assert_receive :update_permissions,
|
||||
1000,
|
||||
assert_receive :update_permissions, 1000,
|
||||
"Should receive :update_permissions when corporation changes"
|
||||
end
|
||||
|
||||
@@ -95,8 +94,7 @@ defmodule WandererApp.Map.CorporationChangePermissionTest do
|
||||
simulate_alliance_removal(character)
|
||||
|
||||
# Should receive :update_permissions broadcast
|
||||
assert_receive :update_permissions,
|
||||
1000,
|
||||
assert_receive :update_permissions, 1000,
|
||||
"Should receive :update_permissions when alliance is removed"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -116,7 +116,6 @@ defmodule WandererApp.Map.Server.AclScopesPropagationTest do
|
||||
|
||||
# Fetch again to confirm persistence
|
||||
{:ok, refetched_map} = WandererApp.MapRepo.get(map.id, [])
|
||||
|
||||
assert refetched_map.scopes == [:wormholes, :hi, :low, :null],
|
||||
"Refetched map should have updated scopes"
|
||||
end
|
||||
|
||||
@@ -577,55 +577,35 @@ defmodule WandererApp.Map.Server.MapScopesTest do
|
||||
# All should be valid because no stargates exist in test data = wormhole connections
|
||||
|
||||
# Hi-Sec combinations
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @ls_system_id) ==
|
||||
true,
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @ls_system_id) == true,
|
||||
"Hi->Low should be valid"
|
||||
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @ns_system_id) ==
|
||||
true,
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @ns_system_id) == true,
|
||||
"Hi->Null should be valid"
|
||||
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @pochven_id) ==
|
||||
true,
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @pochven_id) == true,
|
||||
"Hi->Pochven should be valid"
|
||||
|
||||
# Low-Sec combinations
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @ls_system_id, @hs_system_id) ==
|
||||
true,
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @ls_system_id, @hs_system_id) == true,
|
||||
"Low->Hi should be valid"
|
||||
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @ls_system_id, @ns_system_id) ==
|
||||
true,
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @ls_system_id, @ns_system_id) == true,
|
||||
"Low->Null should be valid"
|
||||
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @ls_system_id, @pochven_id) ==
|
||||
true,
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @ls_system_id, @pochven_id) == true,
|
||||
"Low->Pochven should be valid"
|
||||
|
||||
# Null-Sec combinations
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @ns_system_id, @hs_system_id) ==
|
||||
true,
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @ns_system_id, @hs_system_id) == true,
|
||||
"Null->Hi should be valid"
|
||||
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @ns_system_id, @ls_system_id) ==
|
||||
true,
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @ns_system_id, @ls_system_id) == true,
|
||||
"Null->Low should be valid"
|
||||
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @ns_system_id, @pochven_id) ==
|
||||
true,
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @ns_system_id, @pochven_id) == true,
|
||||
"Null->Pochven should be valid"
|
||||
|
||||
# Pochven combinations
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @pochven_id, @hs_system_id) ==
|
||||
true,
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @pochven_id, @hs_system_id) == true,
|
||||
"Pochven->Hi should be valid"
|
||||
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @pochven_id, @ls_system_id) ==
|
||||
true,
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @pochven_id, @ls_system_id) == true,
|
||||
"Pochven->Low should be valid"
|
||||
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @pochven_id, @ns_system_id) ==
|
||||
true,
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @pochven_id, @ns_system_id) == true,
|
||||
"Pochven->Null should be valid"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -464,8 +464,9 @@ defmodule WandererApp.Map.Operations.SignaturesTest do
|
||||
Task.async(fn ->
|
||||
params = %{"solar_system_id" => 30_000_140 + i}
|
||||
result = Signatures.create_signature(conn, params)
|
||||
# Fake solar_system_ids aren't in EVE static data, so we get :invalid_solar_system
|
||||
assert {:error, :invalid_solar_system} = result
|
||||
# We expect either system_not_found (system doesn't exist in test)
|
||||
# or the MapTestHelpers would have caught the map server error
|
||||
assert {:error, :system_not_found} = result
|
||||
end)
|
||||
end)
|
||||
|
||||
|
||||
283
test/wanderer_app/map/map_position_calculator_test.exs
Normal file
283
test/wanderer_app/map/map_position_calculator_test.exs
Normal 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
|
||||
Reference in New Issue
Block a user