fix: fix scroll and size issues with kills widget (#219)

* fix: fix scroll and size issues with kills widget
This commit is contained in:
guarzo
2025-03-06 20:15:22 +04:00
committed by GitHub
parent 994e03945d
commit ee8f9e4d24
16 changed files with 232 additions and 143 deletions
@@ -1,8 +1,11 @@
import React, { useMemo } from 'react';
import { SystemKillsContent } from '../../../mapInterface/widgets/SystemKills/SystemKillsContent/SystemKillsContent';
import { useKillsCounter } from '../../hooks/useKillsCounter';
import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper';
import { WithChildren, WithClassName } from '@/hooks/Mapper/types/common';
import { useMemo } from 'react';
const ITEM_HEIGHT = 35;
const MIN_TOOLTIP_HEIGHT = 40;
type TooltipSize = 'xs' | 'sm' | 'md' | 'lg';
@@ -16,45 +19,39 @@ type KillsBookmarkTooltipProps = {
WithClassName;
export const KillsCounter = ({ killsCount, systemId, className, children, size = 'xs' }: KillsBookmarkTooltipProps) => {
const { isLoading, kills: detailedKills, systemNameMap } = useKillsCounter({ realSystemId: systemId });
const {
isLoading,
kills: detailedKills,
systemNameMap,
} = useKillsCounter({
realSystemId: systemId,
});
// Limit the kills shown to match the killsCount parameter
const limitedKills = useMemo(() => {
if (!detailedKills || detailedKills.length === 0) return [];
return detailedKills.slice(0, killsCount);
}, [detailedKills, killsCount]);
if (!killsCount || limitedKills.length === 0 || !systemId || isLoading) return null;
if (!killsCount || limitedKills.length === 0 || !systemId || isLoading) {
return null;
}
// Calculate a reasonable height for the tooltip based on the number of kills
// but cap it to avoid excessively large tooltips
const maxKillsToShow = Math.min(limitedKills.length, 20);
const tooltipHeight = Math.max(200, Math.min(500, maxKillsToShow * 35));
// Calculate height based on number of kills, but ensure a minimum height
const killsNeededHeight = limitedKills.length * ITEM_HEIGHT;
const tooltipHeight = Math.max(MIN_TOOLTIP_HEIGHT, Math.min(killsNeededHeight, 500));
const tooltipContent = (
<div
style={{
width: '400px',
height: `${tooltipHeight}px`,
maxHeight: '500px',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
className="overflow-hidden"
>
<div className="p-2 border-b border-stone-700 bg-stone-800 text-stone-200 font-medium">
System Kills ({limitedKills.length})
</div>
<div className="flex-1 overflow-hidden">
<SystemKillsContent
kills={limitedKills}
systemNameMap={systemNameMap}
onlyOneSystem={true}
// Don't use autoSize here as we want the virtual scroller to handle scrolling
autoSize={false}
// We've already limited the kills to match killsCount
limit={undefined}
/>
<div className="flex-1 h-full">
<SystemKillsContent kills={limitedKills} systemNameMap={systemNameMap} onlyOneSystem />
</div>
</div>
);
@@ -43,6 +43,7 @@ export const SystemKills: React.FC = React.memo(() => {
systemId,
outCommand,
showAllVisible: visible,
sinceHours: settings.timeRange,
});
const isNothingSelected = !systemId && !visible;
@@ -61,45 +62,41 @@ export const SystemKills: React.FC = React.memo(() => {
}, [kills, settings.whOnly, systemBySolarSystemId, visible]);
return (
<div className="h-full flex flex-col min-h-0">
<div className="flex flex-col flex-1 min-h-0">
<Widget label={<KillsHeader systemId={systemId} onOpenSettings={() => setSettingsDialogVisible(true)} />}>
{!isSubscriptionActive ? (
<div className="w-full h-full flex items-center justify-center">
<span className="select-none text-center text-stone-400/80 text-sm">
Kills available with &#39;Active&#39; map subscription only (contact map administrators)
</span>
</div>
) : isNothingSelected ? (
<div className="w-full h-full flex items-center justify-center">
<span className="select-none text-center text-stone-400/80 text-sm">
No system selected (or toggle Show all systems)
</span>
</div>
) : showLoading ? (
<div className="w-full h-full flex items-center justify-center">
<span className="select-none text-center text-stone-400/80 text-sm">Loading Kills...</span>
</div>
) : error ? (
<div className="w-full h-full flex items-center justify-center">
<span className="select-none text-center text-red-400 text-sm">{error}</span>
</div>
) : !filteredKills || filteredKills.length === 0 ? (
<div className="w-full h-full flex items-center justify-center">
<span className="select-none text-center text-stone-400/80 text-sm">No kills found</span>
</div>
) : (
<div className="w-full h-full" style={{ height: '100%' }}>
<SystemKillsContent
kills={filteredKills}
systemNameMap={systemNameMap}
onlyOneSystem={!visible}
timeRange={settings.timeRange}
/>
</div>
)}
</Widget>
</div>
<div className="h-full flex flex-col">
<Widget label={<KillsHeader systemId={systemId} onOpenSettings={() => setSettingsDialogVisible(true)} />}>
{!isSubscriptionActive ? (
<div className="w-full h-full flex items-center justify-center">
<span className="select-none text-center text-stone-400/80 text-sm">
Kills available with &#39;Active&#39; map subscription only (contact map administrators)
</span>
</div>
) : isNothingSelected ? (
<div className="w-full h-full flex items-center justify-center">
<span className="select-none text-center text-stone-400/80 text-sm">
No system selected (or toggle &quot;Show all systems&quot;)
</span>
</div>
) : showLoading ? (
<div className="w-full h-full flex items-center justify-center">
<span className="select-none text-center text-stone-400/80 text-sm">Loading Kills...</span>
</div>
) : error ? (
<div className="w-full h-full flex items-center justify-center">
<span className="select-none text-center text-red-400 text-sm">{error}</span>
</div>
) : !filteredKills || filteredKills.length === 0 ? (
<div className="w-full h-full flex items-center justify-center">
<span className="select-none text-center text-stone-400/80 text-sm">No kills found</span>
</div>
) : (
<SystemKillsContent
kills={filteredKills}
systemNameMap={systemNameMap}
onlyOneSystem={!visible}
timeRange={settings.timeRange}
/>
)}
</Widget>
{settingsDialogVisible && <KillsSettingsDialog visible setVisible={setSettingsDialogVisible} />}
</div>
@@ -1,14 +1,22 @@
.wrapper {
overflow-x: hidden;
box-sizing: border-box;
}
// Custom scrollbar styling is now handled by the global custom-scrollbar class
.scrollerContent {
width: 100%;
box-sizing: border-box;
overflow-x: hidden;
overflow-y: auto;
}
// VirtualScroller specific styles that can't be handled with Tailwind
.VirtualScroller {
height: 100% !important;
overflow: hidden !important;
}
// Fix for PrimeReact VirtualScroller - these need to be global
:global {
.p-virtualscroller {
display: flex;
flex-direction: column;
}
.p-virtualscroller-content {
flex: 1;
}
}
@@ -1,5 +1,4 @@
import React, { useMemo } from 'react';
import clsx from 'clsx';
import { DetailedKill } from '@/hooks/Mapper/types/kills';
import { VirtualScroller } from 'primereact/virtualscroller';
import { useSystemKillsItemTemplate } from '../hooks/useSystemKillsItemTemplate';
@@ -11,7 +10,6 @@ export interface SystemKillsContentProps {
kills: DetailedKill[];
systemNameMap: Record<string, string>;
onlyOneSystem?: boolean;
autoSize?: boolean;
timeRange?: number;
limit?: number;
}
@@ -20,44 +18,53 @@ export const SystemKillsContent: React.FC<SystemKillsContentProps> = ({
kills,
systemNameMap,
onlyOneSystem = false,
autoSize = false,
timeRange = 4,
limit,
}) => {
const processedKills = useMemo(() => {
if (!kills || kills.length === 0) return [];
// sort by newest first
const sortedKills = kills
.filter(k => k.kill_time)
.sort((a, b) => new Date(b.kill_time!).getTime() - new Date(a.kill_time!).getTime());
if (limit !== undefined) {
return sortedKills.slice(0, limit);
} else {
const now = Date.now();
const cutoff = now - timeRange * 60 * 60 * 1000;
return sortedKills.filter(k => new Date(k.kill_time!).getTime() >= cutoff);
// filter by timeRange
let filteredKills = sortedKills;
if (timeRange !== undefined) {
const cutoffTime = new Date();
cutoffTime.setHours(cutoffTime.getHours() - timeRange);
filteredKills = sortedKills.filter(kill => {
const killTime = new Date(kill.kill_time!).getTime();
return killTime >= cutoffTime.getTime();
});
}
}, [kills, timeRange, limit]);
const computedHeight = autoSize ? Math.max(processedKills.length, 1) * ITEM_HEIGHT : undefined;
const scrollerHeight = autoSize ? `${computedHeight}px` : '100%';
// apply limit if present
if (limit !== undefined) {
return filteredKills.slice(0, limit);
}
return filteredKills;
}, [kills, timeRange, limit]);
const itemTemplate = useSystemKillsItemTemplate(systemNameMap, onlyOneSystem);
// Define style for the VirtualScroller
const virtualScrollerStyle: React.CSSProperties = {
boxSizing: 'border-box',
};
return (
<div className={clsx('w-full h-full', classes.wrapper)}>
<div className="h-full w-full flex flex-col overflow-hidden" data-testid="system-kills-content">
<VirtualScroller
items={processedKills}
itemSize={ITEM_HEIGHT}
itemTemplate={itemTemplate}
autoSize={autoSize}
scrollWidth="100%"
style={{ height: scrollerHeight }}
className={clsx('w-full h-full custom-scrollbar select-none', {
[classes.VirtualScroller]: !autoSize,
})}
className={`w-full h-full flex-1 select-none ${classes.VirtualScroller}`}
style={virtualScrollerStyle}
pt={{
content: {
className: classes.scrollerContent,
className: `custom-scrollbar ${classes.scrollerContent}`,
},
}}
/>
@@ -90,6 +90,14 @@ export const KillsSettingsDialog: React.FC<KillsSettingsDialogProps> = ({ visibl
const excluded = localData.excludedSystems || [];
const timeRangeOptions = [4, 12, 24];
// Ensure timeRange is one of the valid options
useEffect(() => {
if (visible && !timeRangeOptions.includes(localData.timeRange)) {
// If current timeRange is not in options, set it to the default (4 hours)
handleTimeRangeChange(4);
}
}, [visible, localData.timeRange, handleTimeRangeChange]);
return (
<Dialog header="Kills Settings" visible={visible} style={{ width: '440px' }} draggable={false} onHide={handleHide}>
<div className="flex flex-col gap-3 p-2.5">
@@ -14,7 +14,7 @@ export const DEFAULT_KILLS_WIDGET_SETTINGS: KillsWidgetSettings = {
whOnly: true,
excludedSystems: [],
version: 2,
timeRange: 1,
timeRange: 4,
};
function mergeWithDefaults(settings?: Partial<KillsWidgetSettings>): KillsWidgetSettings {
@@ -39,6 +39,8 @@ export function useSystemKills({ systemId, outCommand, showAllVisible = false, s
const [settings] = useKillsWidgetSettings();
const excludedSystems = settings.excludedSystems;
const effectiveSinceHours = sinceHours;
const updateDetailedKills = useCallback(
(newKillsMap: Record<string, DetailedKill[]>) => {
update(prev => {
@@ -84,14 +86,14 @@ export function useSystemKills({ systemId, outCommand, showAllVisible = false, s
for (const [sid, newKills] of Object.entries(killsMap)) {
const existing = updated[sid] ?? [];
const combined = combineKills(existing, newKills, sinceHours);
const combined = combineKills(existing, newKills, effectiveSinceHours);
updated[sid] = combined;
}
return { ...prev, detailedKills: updated };
});
},
[update, sinceHours],
[update, effectiveSinceHours],
);
const fetchKills = useCallback(
@@ -107,13 +109,13 @@ export function useSystemKills({ systemId, outCommand, showAllVisible = false, s
eventType = OutCommand.getSystemsKills;
requestData = {
system_ids: effectiveSystemIds,
since_hours: sinceHours,
since_hours: effectiveSinceHours,
};
} else if (systemId) {
eventType = OutCommand.getSystemKills;
requestData = {
system_id: systemId,
since_hours: sinceHours,
since_hours: effectiveSinceHours,
};
} else {
setIsLoading(false);
@@ -141,7 +143,7 @@ export function useSystemKills({ systemId, outCommand, showAllVisible = false, s
setIsLoading(false);
}
},
[showAllVisible, systemId, outCommand, effectiveSystemIds, sinceHours, mergeKillsIntoGlobal],
[showAllVisible, systemId, outCommand, effectiveSystemIds, effectiveSinceHours, mergeKillsIntoGlobal],
);
const debouncedFetchKills = useMemo(
@@ -154,15 +156,18 @@ export function useSystemKills({ systemId, outCommand, showAllVisible = false, s
);
const finalKills = useMemo(() => {
let result: DetailedKill[] = [];
if (showAllVisible) {
return effectiveSystemIds.flatMap(sid => detailedKills[sid] ?? []);
result = effectiveSystemIds.flatMap(sid => detailedKills[sid] ?? []);
} else if (systemId) {
return detailedKills[systemId] ?? [];
result = detailedKills[systemId] ?? [];
} else if (didFallbackFetch.current) {
return effectiveSystemIds.flatMap(sid => detailedKills[sid] ?? []);
result = effectiveSystemIds.flatMap(sid => detailedKills[sid] ?? []);
}
return [];
}, [showAllVisible, systemId, effectiveSystemIds, detailedKills]);
return result;
}, [showAllVisible, systemId, effectiveSystemIds, detailedKills, didFallbackFetch]);
const effectiveIsLoading = isLoading && finalKills.length === 0;
@@ -178,10 +183,13 @@ export function useSystemKills({ systemId, outCommand, showAllVisible = false, s
if (effectiveSystemIds.length === 0) return;
if (showAllVisible || systemId) {
debouncedFetchKills();
// Cancel any pending debounced fetch
debouncedFetchKills.cancel();
// Fetch kills immediately
fetchKills();
return () => debouncedFetchKills.cancel();
}
}, [showAllVisible, systemId, effectiveSystemIds, debouncedFetchKills]);
}, [showAllVisible, systemId, effectiveSystemIds, debouncedFetchKills, fetchKills]);
const refetch = useCallback(() => {
debouncedFetchKills.cancel();
+1 -1
View File
@@ -556,7 +556,7 @@ defmodule WandererApp.Character.Tracker do
{:ok, [character_aff_info]} when not is_nil(character_aff_info) ->
update_corporation(state, character_aff_info |> Map.get("corporation_id"))
error ->
_error ->
state
end
end
+2 -2
View File
@@ -119,10 +119,10 @@ defmodule WandererApp.Maps do
@decorate cacheable(
cache: WandererApp.Cache,
key: "map_characters-#{_map_id}",
key: "map_characters-#{map_id}",
opts: [ttl: :timer.seconds(5)]
)
defp _get_map_characters(%{id: _map_id} = map) do
defp _get_map_characters(%{id: map_id} = map) do
map_acls =
map.acls
|> Enum.map(fn acl -> acl |> Ash.load!(:members) end)
@@ -14,7 +14,6 @@ defmodule WandererApp.Zkb.KillsProvider.Parser do
use Retry
# Maximum retries for enrichment calls
@max_enrichment_retries 2
@doc """
Merges the 'partial' from zKB and the 'full' killmail from ESI, checks its time
@@ -220,20 +220,6 @@ defmodule WandererAppWeb.MapAccessListAPIController do
end
end
# Helper to find a character by internal id.
defp find_character_by_id(id) do
query =
Character
|> Ash.Query.new()
|> filter(id == ^id)
case WandererApp.Api.read(query) do
{:ok, [character]} -> {:ok, character}
{:ok, []} -> {:error, "Character not found"}
other -> other
end
end
# Helper to associate a new ACL with a map.
defp associate_acl_with_map(map, new_acl) do
with {:ok, api_map} <- WandererApp.Api.Map.by_id(map.id),
@@ -184,13 +184,12 @@ defmodule WandererAppWeb.MapAPIController do
Returns kills data for all *visible* systems on the map.
Requires either `?map_id=<UUID>` or `?slug=<map-slug>`.
Optional hours_ago
Optional hours_ago parameter.
Example:
GET /api/map/systems_kills?map_id=<uuid>
GET /api/map/systems_kills?slug=<map-slug>
GET /api/map/systems_kills?map_id=<uuid>&hour_ago=<somehours>
GET /api/map/systems_kills?map_id=<uuid>&hours_ago=<somehours>
"""
def list_systems_kills(conn, params) do
with {:ok, map_id} <- Util.fetch_map_id(params),
@@ -199,8 +198,10 @@ defmodule WandererAppWeb.MapAPIController do
Logger.debug(fn -> "[list_systems_kills] Found #{length(systems)} visible systems for map_id=#{map_id}" end)
# Parse the hours_ago param
hours_ago = parse_hours_ago(params["hours_ago"])
# Parse the hours_ago param (check both "hours_ago" and "hour_ago" for backward compatibility)
hours_ago = parse_hours_ago(params["hours_ago"] || params["hour_ago"])
Logger.debug(fn -> "[list_systems_kills] Using hours_ago=#{inspect(hours_ago)}, from params: hours_ago=#{inspect(params["hours_ago"])}, hour_ago=#{inspect(params["hour_ago"])}" end)
# Gather system IDs
solar_ids = Enum.map(systems, & &1.solar_system_id)
@@ -216,11 +217,11 @@ defmodule WandererAppWeb.MapAPIController do
# Filter out kills older than hours_ago
filtered_kills = maybe_filter_kills_by_time(kills, hours_ago)
Logger.debug(fn -> "
[list_systems_kills] For system_id=#{sys.solar_system_id},
found #{length(kills)} kills total,
returning #{length(filtered_kills)} kills after hours_ago filter
" end)
Logger.debug(fn ->
"[list_systems_kills] For system_id=#{sys.solar_system_id}, " <>
"found #{length(kills)} kills total, " <>
"returning #{length(filtered_kills)} kills after hours_ago=#{inspect(hours_ago)} filter"
end)
%{
solar_system_id: sys.solar_system_id,
@@ -231,7 +232,7 @@ defmodule WandererAppWeb.MapAPIController do
json(conn, %{data: data})
else
{:error, msg} when is_binary(msg) ->
Logger.warn("[list_systems_kills] Bad request: #{msg}")
Logger.warning("[list_systems_kills] Bad request: #{msg}")
conn
|> put_status(:bad_request)
|> json(%{error: msg})
@@ -244,34 +245,74 @@ defmodule WandererAppWeb.MapAPIController do
end
end
@doc """
GET /api/map/systems-kills
This is an alias for list_systems_kills to support the hyphenated URL format.
See list_systems_kills for full documentation.
"""
def list_systems_kills_hyphenated(conn, params) do
list_systems_kills(conn, params)
end
# If hours_str is present and valid, parse it. Otherwise return nil (no filter).
defp parse_hours_ago(nil), do: nil
defp parse_hours_ago(hours_str) do
case Integer.parse(hours_str) do
{num, ""} when num > 0 -> num
_ -> nil
Logger.debug(fn -> "[parse_hours_ago] Parsing hours_str: #{inspect(hours_str)}" end)
result = case Integer.parse(hours_str) do
{num, ""} when num > 0 ->
Logger.debug(fn -> "[parse_hours_ago] Successfully parsed to #{num}" end)
num
{num, rest} ->
Logger.debug(fn -> "[parse_hours_ago] Parsed with remainder: #{num}, rest: #{inspect(rest)}" end)
nil
:error ->
Logger.debug(fn -> "[parse_hours_ago] Failed to parse" end)
nil
end
Logger.debug(fn -> "[parse_hours_ago] Final result: #{inspect(result)}" end)
result
end
defp maybe_filter_kills_by_time(kills, hours_ago) when is_integer(hours_ago) do
cutoff = DateTime.utc_now() |> DateTime.add(-hours_ago * 3600, :second)
Logger.debug(fn -> "[maybe_filter_kills_by_time] Filtering kills with cutoff: #{DateTime.to_iso8601(cutoff)}" end)
Enum.filter(kills, fn kill ->
filtered = Enum.filter(kills, fn kill ->
kill_time = kill["kill_time"]
case kill_time do
result = case kill_time do
%DateTime{} = dt ->
# Keep kills that occurred after the cutoff
DateTime.compare(dt, cutoff) != :lt
time when is_binary(time) ->
# Try to parse the string time
case DateTime.from_iso8601(time) do
{:ok, dt, _} -> DateTime.compare(dt, cutoff) != :lt
_ -> false
end
# If it's something else (nil, or a weird format), skip
_ ->
false
end
Logger.debug(fn ->
kill_time_str = if is_binary(kill_time), do: kill_time, else: inspect(kill_time)
"[maybe_filter_kills_by_time] Kill time: #{kill_time_str}, included: #{result}"
end)
result
end)
Logger.debug(fn -> "[maybe_filter_kills_by_time] Filtered #{length(kills)} kills to #{length(filtered)} kills" end)
filtered
end
# If hours_ago is nil, maybe no time filtering:
# If hours_ago is nil, no time filtering:
defp maybe_filter_kills_by_time(kills, nil), do: kills
defp handle_all_structure_timers(conn, map_id) do
@@ -123,8 +123,12 @@ defmodule WandererAppWeb.MapCoreEventHandler do
socket
end
def handle_server_event(%{payload: kill_data}, socket) when is_map(kill_data) and map_size(kill_data) > 0 do
socket
end
def handle_server_event(event, socket) do
Logger.warning(fn -> "unhandled map core event: #{inspect(event)} #{inspect(socket)} " end)
Logger.warning(fn -> "unhandled map core event: #{inspect(event)}" end)
socket
end
@@ -100,10 +100,45 @@ defmodule WandererAppWeb.MapKillsEventHandler do
def handle_ui_event("get_systems_kills", %{"system_ids" => sids, "since_hours" => sh} = payload, socket) do
with {:ok, since_hours} <- parse_id(sh),
{:ok, parsed_ids} <- parse_system_ids(sids) do
Logger.debug(fn -> "[#{__MODULE__}] get_systems_kills => system_ids=#{inspect(parsed_ids)}, since_hours=#{since_hours}" end)
# Get the cutoff time based on since_hours
cutoff = DateTime.utc_now() |> DateTime.add(-since_hours * 3600, :second)
Logger.debug(fn -> "[#{__MODULE__}] get_systems_kills => cutoff=#{DateTime.to_iso8601(cutoff)}" end)
# Fetch and filter kills for each system
cached_map =
Enum.reduce(parsed_ids, %{}, fn sid, acc ->
kills_list = KillsCache.fetch_cached_kills(sid)
Map.put(acc, sid, kills_list)
# Get all cached kills for this system
all_kills = KillsCache.fetch_cached_kills(sid)
# Filter kills based on the cutoff time
filtered_kills = Enum.filter(all_kills, fn kill ->
kill_time = kill["kill_time"]
case kill_time do
%DateTime{} = dt ->
# Keep kills that occurred after the cutoff
DateTime.compare(dt, cutoff) != :lt
time when is_binary(time) ->
# Try to parse the string time
case DateTime.from_iso8601(time) do
{:ok, dt, _} -> DateTime.compare(dt, cutoff) != :lt
_ -> false
end
# If it's something else (nil, or a weird format), skip
_ ->
false
end
end)
Logger.debug(fn ->
"[#{__MODULE__}] get_systems_kills => system_id=#{sid}, all_kills=#{length(all_kills)}, filtered_kills=#{length(filtered_kills)}"
end)
Map.put(acc, sid, filtered_kills)
end)
reply_payload = %{"systems_kills" => cached_map}
+1 -1
View File
@@ -675,7 +675,7 @@ defmodule WandererAppWeb.MapsLive do
map
|> WandererApp.Api.Map.update(form)
|> case do
{:ok, updated_map} ->
{:ok, _updated_map} ->
{added_acls, removed_acls} = map.acls |> Enum.map(& &1.id) |> _get_acls_diff(form["acls"])
Phoenix.PubSub.broadcast(
-1
View File
@@ -4,7 +4,6 @@ defmodule WandererAppWeb.Router do
use Plug.ErrorHandler
import PlugDynamic.Builder
import Logger
import WandererAppWeb.UserAuth,
warn: false,