Visualization, maps, animation & history

I was playing with Google AI Studio in the week-end to see if I could understand some language/culture history. So I asked AI Studio to develop some Julia code for doing an animation of Indo-European spread. Here is a snapshot of the final image:

I have mixed experience with this process – it took some 40-50 versions before I got something that is reasonable.

Some caution: The code that was developed is based on several hypotheses which may or may not be correct:

  • Southern Arch origin (perhaps makes most sense genetically, see papers by Reich et al. in the early 2020s, but linguists oppose this idea for good reasons).
  • Assuming the Steppe theory is correct – probably the prevailing theory, but not all agree (e.g., the out-of-India theory).
  • The origin of proto-Slavic as late as 300 CE seems dubious.

In summary:

  • AI Studio used a particle swarm to describe the movement. But it doesn’t really show the spread – the particle swarms are too dense. Ideally, it would perhaps look nicer with a “cloud” with boundaries, but AI Studio claimed that was difficult to achieve.
  • With the “swarm”, it is tricky to get a concentrated movement of people such as the movement of Vandals through Spain via Gibraltar and east to Carthage. In most early simulations, the swarm “swam” from Turin to Carthage via Sardinia, or from Barcelona via Mallorca… Possibly because this movement took place over a very short time, and the particles didn’t follow the “leader” immediately.
  • It is tricky to get “events” (formation of cultures, like the Vedic culture in India) to pop up at the correct time, and with the reasonable spread. In particular, there are difficulties when several things happen more or less at the same time. AI Studio “cheated” and moved some events apart to make this happen, so a more robust implementation would help.

Questions
Are there any tools in the Julia visualization category that could have done what I have tried to do?

  • I have lots of ideas about improvements to the code, e.g., generalizing groups and events in a separate file to make it easier to use for other studies, include important rivers (e.g., Don, Danube, Rhine, etc.), vegetation (steppe vs. forest, etc.), etc.
  • I think it would be a useful tool for people working at the intersection of education, geography, history, archelogy, linguistics, genetics.

OK – to me, it was an interesting week-end project. Don’t know if anyone else is interested in similar things (not talking about the particular language group, but the general idea). If anyone are interested, I include the code that AI Studio developed below.

using GLMakie
using Tyler
using TileProviders
using Distributions
using Random
using Colors
#
# --- 1. GEOGRAPHY HELPER ---
function latlon_to_mercator(lat, lon)
    r = 6378137.0
    x = r * deg2rad(lon)
    y = r * log(tan(π/4 + deg2rad(lat)/2))
    return Point2f(x, y)
end

# --- 2. DATA STRUCTURES ---
mutable struct CultureGroup
    name::String
    color::Observable{Symbol} 
    opacity::Observable{Float64} 
    center_lat::Float64
    center_lon::Float64
    target_lat::Float64
    target_lon::Float64
    active::Bool
    mobility::Float64 
    particles::Observable{Vector{Point2f}}
end

function create_group(name, color_sym, lat, lon, n_particles=250; mobility=1.0, spread=200000.0)
    center = latlon_to_mercator(lat, lon)
    pts = [center + Point2f(randn()*spread, randn()*spread) for _ in 1:n_particles]
    return CultureGroup(
        name, Observable(color_sym), Observable(0.75), 
        lat, lon, lat, lon, 
        true, mobility, Observable(pts)
    )
end

# --- 3. SETUP MAP ---
f = Figure(size = (1400, 900), backgroundcolor = :white)
ax = Axis(f[1, 1], aspect = DataAspect())
hidedecorations!(ax); hidespines!(ax)

provider = TileProviders.Esri(:WorldPhysical)
m = Tyler.Map(Rect2f(-15, 20, 115, 55); provider=provider, figure=f, axis=ax)

# Zones
zone_pos = Observable(Point2f[])
zone_color = Observable(RGBAf[])
zone_size = Observable(Float64[])
scatter!(ax, zone_pos, color=zone_color, markersize=zone_size, marker=:circle)

display(f)
println("Downloading map tiles... (Please wait)...")
sleep(5)
wait(m)

# --- 4. LEGEND ---
leg_elements = [
    MarkerElement(color = (:maroon, 0.8), marker = :circle, markersize = 15),
    MarkerElement(color = (:teal, 0.8), marker = :circle, markersize = 15),
    MarkerElement(color = (:red, 0.8), marker = :circle, markersize = 15),
    MarkerElement(color = (:steelblue, 0.8), marker = :circle, markersize = 15),
    MarkerElement(color = (:mediumorchid, 0.8), marker = :circle, markersize = 15),
    MarkerElement(color = (:slateblue, 0.8), marker = :circle, markersize = 15), 
    MarkerElement(color = (:gold, 0.8), marker = :circle, markersize = 15),
    MarkerElement(color = (:dodgerblue, 0.8), marker = :circle, markersize = 15),
    MarkerElement(color = (:darkorange, 0.8), marker = :circle, markersize = 15),
    MarkerElement(color = (:orangered, 0.8), marker = :circle, markersize = 15),
    MarkerElement(color = (:firebrick, 0.8), marker = :circle, markersize = 15),
    MarkerElement(color = (:brown, 0.8), marker = :circle, markersize = 15),
    MarkerElement(color = (:limegreen, 0.8), marker = :circle, markersize = 15),
    MarkerElement(color = (:forestgreen, 0.8), marker = :circle, markersize = 15),
    MarkerElement(color = (:purple, 0.8), marker = :circle, markersize = 15),
    MarkerElement(color = (:violet, 0.8), marker = :circle, markersize = 15)
]

leg_labels = [
    "Southern Arc (Proto-Indo-Anatolian)",
    "Eastern Hunter Gatherers (EHG)",
    "Yamnaya (The Merger)",
    "Forest Steppe",
    "Corded Ware",
    "Battle Axe",
    "Tocharian / Afanasievo",
    "Greek",
    "Armenian",
    "Indo-Aryan / Sintashta",
    "Iranian",
    "Scythian/Alan",
    "Celtic / Bell Beaker",
    "Italic / Roman",
    "Germanic / Vandal",
    "Slavic"
]

Legend(f[1, 2], leg_elements, leg_labels, "Cultural Groups", framevisible = false)

# --- 5. INITIALIZE START (4000 BCE) ---
groups = Dict{String, CultureGroup}()

groups["SouthernArc"] = create_group("SouthernArc", :maroon, 39.0, 45.0, 300)
groups["EHG"] = create_group("EHG", :teal, 48.0, 40.0, 300)
groups["CordedWare"] = create_group("CordedWare", :steelblue, 53.0, 38.0, 300) 
groups["Farmers"] = create_group("Farmers", :gray, 52.0, 18.0, 200; spread=300000.0)
groups["Farmers"].opacity[] = 0.5
groups["PittedWare"] = create_group("PittedWare", :khaki, 60.0, 15.0, 150; spread=250000.0)

for g in values(groups)
    c_lift = lift((c, o) -> (c, o), g.color, g.opacity)
    scatter!(ax, g.particles, color=c_lift, markersize=6)
end

year_text = Observable("Year: 4000 BCE")
Label(f[1, 1, Top()], year_text, fontsize=24, font=:bold)
event_text = Observable("Event: Proto-Indo-European Unity")
Label(f[1, 1, Bottom()], event_text, fontsize=20, color=:black)

# --- HELPERS ---
function show_zone(lat, lon, color_sym, size_px)
    pt = latlon_to_mercator(lat, lon)
    zone_pos[] = [pt]
    c = parse(Colorant, string(color_sym))
    zone_color[] = [RGBAf(c, 0.6)] 
    zone_size[] = [size_px]
end

function clear_zone()
    zone_pos[] = Point2f[]; zone_color[] = RGBAf[]; zone_size[] = Float64[]
end

function lerp(a, b, t)
    return a + (b - a) * t
end

function get_vandal_pos(year)
    segments = [
        (418, 425, (40.0, -4.0), (37.4, -6.0)),  # Portugal -> Seville
        (425, 428, (37.4, -6.0), (36.0, -5.6)),  # Seville -> Gibraltar
        (428, 429, (36.0, -5.6), (35.7, -5.8)),  # CROSSING -> Tangier
        (429, 433, (35.7, -5.8), (35.7, -0.6)),  # Tangier -> Oran
        (433, 436, (35.7, -0.6), (36.7, 3.0)),   # Oran -> Algiers
        (436, 439, (36.7, 3.0), (36.8, 10.2))    # Algiers -> Carthage
    ]
    for (start_y, end_y, start_pos, end_pos) in segments
        if year >= start_y && year <= end_y
            t = (year - start_y) / (end_y - start_y)
            lat = lerp(start_pos[1], end_pos[1], t)
            lon = lerp(start_pos[2], end_pos[2], t)
            return lat, lon
        end
    end
    return nothing
end

# --- 6. HISTORY ENGINE ---
function update_history!(groups, year, ax)
    should_pause = false

    # --- 3800 BCE: THE SPLIT ---
    if year == -3800
        event_text[] = "Event: Southern Arc Splits. Anatolians West, Steppe Ancestors North."
        groups["Anatolian"] = create_group("Anatolian", :darkred, 39.0, 44.0, 150)
        c_lift = lift((c, o) -> (c, o), groups["Anatolian"].color, groups["Anatolian"].opacity)
        scatter!(ax, groups["Anatolian"].particles, color=c_lift, markersize=6)
        groups["Anatolian"].target_lat = 39.0; groups["Anatolian"].target_lon = 32.0
        groups["SouthernArc"].target_lat = 47.0; groups["SouthernArc"].target_lon = 42.0
    end

    # --- 3500 BCE: YAMNAYA MERGER ---
    if year == -3500
        event_text[] = "Event: Southern Arc + EHG = YAMNAYA"
        groups["Yamnaya"] = create_group("Yamnaya", :red, 47.0, 40.0, 450)
        c_lift = lift((c, o) -> (c, o), groups["Yamnaya"].color, groups["Yamnaya"].opacity)
        scatter!(ax, groups["Yamnaya"].particles, color=c_lift, markersize=6)
        groups["SouthernArc"].active = false; groups["SouthernArc"].opacity[] = 0.0
        groups["EHG"].active = false; groups["EHG"].opacity[] = 0.0
        show_zone(47.0, 40.0, :red, 150.0)
        should_pause = true
    end

    # --- 3300 BCE: TOCHARIAN START ---
    if year == -3300
        # Start moving early
        groups["Tocharian"] = create_group("Tocharian", :gold, 50.0, 50.0, 150; mobility=15.0)
        c_lift = lift((c, o) -> (c, o), groups["Tocharian"].color, groups["Tocharian"].opacity)
        scatter!(ax, groups["Tocharian"].particles, color=c_lift, markersize=6)
        groups["Tocharian"].target_lat = 42.0; groups["Tocharian"].target_lon = 88.0 
    end
    
    # --- 2900 BCE: AFANASIEVO EVENT ---
    if year == -2900
        event_text[] = "Event: Afanasievo Culture (Proto-Tocharian)"
        show_zone(48.0, 88.0, :gold, 120.0) # Altai Highlight
        should_pause = true
    end

    # Forest Steppe moves West
    if year > -3500 && year < -2800
        groups["CordedWare"].target_lat = 53.0; groups["CordedWare"].target_lon = 25.0
    end

    # --- 2800 BCE: CORDED WARE EVENT (Shifted to avoid conflict) ---
    if year == -2800
        event_text[] = "EVENT: Forest Steppe + Farmers = Corded Ware"
        groups["CordedWare"].color[] = :mediumorchid 
        groups["CordedWare"].target_lat = 54.0; groups["CordedWare"].target_lon = 20.0
        groups["Farmers"].opacity[] = 0.0; groups["Farmers"].active = false
        show_zone(54.0, 20.0, :mediumorchid, 150.0)
        should_pause = true
    end

    if year >= -2800 && year < -2790
        groups["Yamnaya"].target_lat = 47.0; groups["Yamnaya"].target_lon = 19.0
    end

    # --- GREEK MIGRATION ---
    if year == -2800
        groups["Greek"] = create_group("Greek", :dodgerblue, 47.0, 28.0, 150; mobility=1.5)
        c_lift = lift((c, o) -> (c, o), groups["Greek"].color, groups["Greek"].opacity)
        scatter!(ax, groups["Greek"].particles, color=c_lift, markersize=6)
        groups["Greek"].target_lat = 38.0; groups["Greek"].target_lon = 22.0
    end

    # --- 2700 BCE: BATTLE AXE ---
    if year == -2700
        groups["BattleAxe"] = create_group("BattleAxe", :slateblue, 54.0, 20.0, 150)
        c_lift = lift((c, o) -> (c, o), groups["BattleAxe"].color, groups["BattleAxe"].opacity)
        scatter!(ax, groups["BattleAxe"].particles, color=c_lift, markersize=6)
        groups["BattleAxe"].target_lat = 60.0; groups["BattleAxe"].target_lon = 14.0
    end
    if year == -2600
        groups["PittedWare"].opacity[] = 0.0; groups["BattleAxe"].color[] = :darkslateblue 
        groups["BattleAxeJutland"] = create_group("BattleAxeJutland", :slateblue, 58.0, 12.0, 150)
        c_lift = lift((c, o) -> (c, o), groups["BattleAxeJutland"].color, groups["BattleAxeJutland"].opacity)
        scatter!(ax, groups["BattleAxeJutland"].particles, color=c_lift, markersize=6)
        groups["BattleAxeJutland"].target_lat = 56.0; groups["BattleAxeJutland"].target_lon = 9.5
    end

    # --- 2450 BCE: BEAKERS ---
    if year == -2450
        groups["Yamnaya"].color[] = :limegreen 
        groups["Yamnaya"].target_lat = 48.0; groups["Yamnaya"].target_lon = 5.0 
        
        groups["BeakerNorth"] = create_group("BeakerNorth", :limegreen, 50.0, 6.0, 150)
        c_lift = lift((c, o) -> (c, o), groups["BeakerNorth"].color, groups["BeakerNorth"].opacity)
        scatter!(ax, groups["BeakerNorth"].particles, color=c_lift, markersize=6)
        groups["BeakerNorth"].target_lat = 56.0; groups["BeakerNorth"].target_lon = 9.5
    end

    # --- 2400 BCE: ARMENIAN ---
    if year == -2400
        groups["Armenian"] = create_group("Armenian", :darkorange, 46.0, 30.0, 150)
        c_lift = lift((c, o) -> (c, o), groups["Armenian"].color, groups["Armenian"].opacity)
        scatter!(ax, groups["Armenian"].particles, color=c_lift, markersize=6)
        groups["Armenian"].target_lat = 44.0; groups["Armenian"].target_lon = 42.0
    end
    if year == -1200
        groups["Armenian"].target_lat = 40.0; groups["Armenian"].target_lon = 44.0
    end

    # --- 2200 BCE: JUTLAND EVENT (Arrival) ---
    if year == -2200
        event_text[] = "Event: Bell Beaker meets Battle Axe (Jutland)"
        show_zone(56.0, 9.5, :limegreen, 120.0)
        should_pause = true
    end

    # --- 2100 BCE: SINTASHTA (Spawn Aryans Here) ---
    if year == -2100
        event_text[] = "EVENT: Sintashta Culture (Chariots)"
        # FIX: SPAWN Aryans HERE and NOW.
        groups["Aryan"] = create_group("Indo-Iranian", :orangered, 52.0, 60.0, 250; mobility=4.0) # Moderate speed is fine now
        c_lift = lift((c, o) -> (c, o), groups["Aryan"].color, groups["Aryan"].opacity)
        scatter!(ax, groups["Aryan"].particles, color=c_lift, markersize=6)
        # Start moving immediately
        groups["Aryan"].target_lat = 45.0; groups["Aryan"].target_lon = 70.0 
        
        show_zone(52.0, 60.0, :orangered, 150.0) 
        should_pause = true
    end
    
    if year == -1900
        # Push Aryans to India
        groups["Aryan"].target_lat = 30.0; groups["Aryan"].target_lon = 75.0 
    end

    # --- 2000 BCE: GERMANIC GENESIS ---
    if year == -2000
        event_text[] = "EVENT: Germanic Culture Forms (Jutland)"
        groups["Germanic"] = create_group("Germanic", :purple, 56.0, 9.5, 200)
        c_lift = lift((c, o) -> (c, o), groups["Germanic"].color, groups["Germanic"].opacity)
        scatter!(ax, groups["Germanic"].particles, color=c_lift, markersize=7)
        
        if haskey(groups, "BeakerNorth") groups["BeakerNorth"].active = false; groups["BeakerNorth"].opacity[] = 0.0 end
        if haskey(groups, "BattleAxeJutland") groups["BattleAxeJutland"].active = false; groups["BattleAxeJutland"].opacity[] = 0.0 end
        
        show_zone(56.0, 9.5, :purple, 120.0)
        should_pause = true
    end
    
    # --- 1900 BCE: ÚNĚTICE CULTURE (Separate Year) ---
    if year == -1900
        event_text[] = "EVENT: Únětice Culture (Bronze Age Dawn)"
        show_zone(50.5, 13.5, :olive, 120.0) # Olive Zone
        should_pause = true
    end

    # --- 1700 BCE: NORDIC BRONZE AGE ---
    if year == -1700
        groups["GermanicCore"] = create_group("GermanicCore", :purple, 58.0, 12.0, 400; spread=350000.0)
        c_lift = lift((c, o) -> (c, o), groups["GermanicCore"].color, groups["GermanicCore"].opacity)
        scatter!(ax, groups["GermanicCore"].particles, color=c_lift, markersize=7)
        if haskey(groups, "Germanic") groups["Germanic"].active = false; groups["Germanic"].opacity[] = 0.0 end
        if haskey(groups, "BattleAxe") groups["BattleAxe"].active = false; groups["BattleAxe"].opacity[] = 0.0 end
    end

    # --- 1600 BCE: MYCENAEAN GREECE ---
    if year == -1600
        event_text[] = "EVENT: Mycenaean Greece"
        show_zone(38.0, 22.0, :dodgerblue, 120.0)
        should_pause = true
    end

    # --- 1500 BCE: VEDIC INDIA (Arrival) ---
    if year == -1500
        event_text[] = "EVENT: Vedic Civilization (Punjab)"
        show_zone(30.0, 72.0, :orangered, 150.0)
        should_pause = true
    end

    # --- 1300 BCE: URNFIELD ---
    if year == -1300
        event_text[] = "EVENT: Urnfield Culture"
        show_zone(48.0, 12.0, :limegreen, 150.0) 
        should_pause = true
    end

    # --- 1200 BCE: IRON AGE ---
    if year == -1200
        groups["Anatolian"].opacity[] = 0.35 
        groups["Scythian"] = create_group("Scythian", :brown, 48.0, 60.0, 250)
        c_lift = lift((c, o) -> (c, o), groups["Scythian"].color, groups["Scythian"].opacity)
        scatter!(ax, groups["Scythian"].particles, color=c_lift, markersize=6)
        groups["Scythian"].target_lat = 48.0; groups["Scythian"].target_lon = 30.0
        
        groups["Italic"] = create_group("Italic", :forestgreen, 47.0, 12.0, 100)
        c_lift = lift((c, o) -> (c, o), groups["Italic"].color, groups["Italic"].opacity)
        scatter!(ax, groups["Italic"].particles, color=c_lift, markersize=6)
        groups["Italic"].target_lat = 41.0; groups["Italic"].target_lon = 12.5
    end

    if year == -1000
        groups["Aryan"].target_lat = 28.0; groups["Aryan"].target_lon = 77.0 
        groups["Iranian"] = create_group("Iranian", :firebrick, 40.0, 65.0, 150)
        c_lift = lift((c, o) -> (c, o), groups["Iranian"].color, groups["Iranian"].opacity)
        scatter!(ax, groups["Iranian"].particles, color=c_lift, markersize=6)
        groups["Iranian"].target_lat = 32.0; groups["Iranian"].target_lon = 53.0
    end

    if year == -900
        groups["Celtiberian"] = create_group("Celtiberian", :limegreen, 45.0, 4.0, 100)
        c_lift = lift((c, o) -> (c, o), groups["Celtiberian"].color, groups["Celtiberian"].opacity)
        scatter!(ax, groups["Celtiberian"].particles, color=c_lift, markersize=6)
        groups["Celtiberian"].target_lat = 40.0; groups["Celtiberian"].target_lon = -4.0
    end
    
    if year == -800
        event_text[] = "EVENT: Hallstatt Culture (Celts)"
        show_zone(47.5, 13.5, :limegreen, 150.0)
        should_pause = true
    end

    if year == -550
        event_text[] = "EVENT: Achaemenid Persian Empire"
        show_zone(32.0, 53.0, :firebrick, 200.0)
        should_pause = true
    end

    if year == -500
        event_text[] = "EVENT: Classical Athens"
        show_zone(38.0, 23.7, :dodgerblue, 80.0)
        should_pause = true
        groups["Briton"] = create_group("Briton", :limegreen, 50.0, 2.0, 100)
        c_lift = lift((c, o) -> (c, o), groups["Briton"].color, groups["Briton"].opacity)
        scatter!(ax, groups["Briton"].particles, color=c_lift, markersize=6)
        groups["Briton"].target_lat = 54.0; groups["Briton"].target_lon = -2.0
    end

    if year == -450
        event_text[] = "EVENT: La Tène Culture"
        show_zone(47.0, 7.0, :limegreen, 180.0) 
        should_pause = true
    end
    
    if year == -400
        groups["Boii"] = create_group("Boii", :limegreen, 48.0, 10.0, 80)
        c_lift = lift((c, o) -> (c, o), groups["Boii"].color, groups["Boii"].opacity)
        scatter!(ax, groups["Boii"].particles, color=c_lift, markersize=6)
        groups["Boii"].target_lat = 49.0; groups["Boii"].target_lon = 15.0 
    end
    
    if year == -270
        groups["Galatian"] = create_group("Galatian", :limegreen, 42.0, 22.0, 80)
        c_lift = lift((c, o) -> (c, o), groups["Galatian"].color, groups["Galatian"].opacity)
        scatter!(ax, groups["Galatian"].particles, color=c_lift, markersize=6)
        groups["Galatian"].target_lat = 39.0; groups["Galatian"].target_lon = 32.0
    end
    
    if year == -30
        event_text[] = "EVENT: The Roman Empire"
        show_zone(41.9, 12.5, :forestgreen, 200.0) # Forest Green
        should_pause = true
    end
    
    if year == 0
        if haskey(groups, "Yamnaya") groups["Yamnaya"].opacity[] = 0.35 end 
        if haskey(groups, "Celtiberian") groups["Celtiberian"].opacity[] = 0.35 end
        if haskey(groups, "Boii") groups["Boii"].opacity[] = 0.35 end
        if haskey(groups, "Galatian") groups["Galatian"].opacity[] = 0.35 end
    end

    if year == 300
        event_text[] = "EVENT: Proto-Slavic Culture Forms"
        groups["Slav"] = create_group("Slav", :violet, 52.0, 28.0, 300) 
        c_lift = lift((c, o) -> (c, o), groups["Slav"].color, groups["Slav"].opacity)
        scatter!(ax, groups["Slav"].particles, color=c_lift, markersize=6)
        show_zone(52.0, 28.0, :violet, 150.0)
        should_pause = true
    end

    if year == 400
        groups["Saxon"] = create_group("Saxon", :purple, 54.0, 9.0, 120; mobility=2.0)
        c_lift = lift((c, o) -> (c, o), groups["Saxon"].color, groups["Saxon"].opacity)
        scatter!(ax, groups["Saxon"].particles, color=c_lift, markersize=6)
    end
    if year == 410
        event_text[] = "Event: Roman Withdrawal from Britain"
        groups["Saxon"].target_lat = 52.0; groups["Saxon"].target_lon = -1.5 
        if haskey(groups, "Briton") groups["Briton"].opacity[] = 0.35 end
    end

    if year == 150
        event_text[] = "Event: Goths move to Black Sea"
        groups["Ostrogoth"] = create_group("Ostrogoth", :purple, 52.0, 20.0, 100; mobility=2.0)
        c_lift = lift((c, o) -> (c, o), groups["Ostrogoth"].color, groups["Ostrogoth"].opacity)
        scatter!(ax, groups["Ostrogoth"].particles, color=c_lift, markersize=6)
        groups["Ostrogoth"].target_lat = 46.0; groups["Ostrogoth"].target_lon = 34.0 
        
        groups["Visigoth"] = create_group("Visigoth", :purple, 52.0, 18.0, 100; mobility=4.0)
        c_lift = lift((c, o) -> (c, o), groups["Visigoth"].color, groups["Visigoth"].opacity)
        scatter!(ax, groups["Visigoth"].particles, color=c_lift, markersize=6)
        groups["Visigoth"].target_lat = 44.0; groups["Visigoth"].target_lon = 25.0
    end
    
    if year == 380
        event_text[] = "Event: Vandals/Alans Form"
        groups["Vandal"] = create_group("Vandal", :purple, 50.0, 15.0, 150; mobility=5.0, spread=100000.0)
        c_lift = lift((c, o) -> (c, o), groups["Vandal"].color, groups["Vandal"].opacity)
        scatter!(ax, groups["Vandal"].particles, color=c_lift, markersize=6)
        
        groups["Alan"] = create_group("Alan", :brown, 48.0, 20.0, 80; mobility=5.0, spread=100000.0)
        c_lift = lift((c, o) -> (c, o), groups["Alan"].color, groups["Alan"].opacity)
        scatter!(ax, groups["Alan"].particles, color=c_lift, markersize=6)
    end
    
    # --- MOVEMENT LOGIC ---
    if year == 400
        groups["Visigoth"].target_lat = 42.0; groups["Visigoth"].target_lon = 12.0 
        groups["Vandal"].target_lat = 46.0; groups["Vandal"].target_lon = 2.0 
        groups["Alan"].target_lat = 46.0; groups["Alan"].target_lon = 2.0
    end
    
    if year == 406
        event_text[] = "Event: Vandals cross Rhine"
        groups["Vandal"].target_lat = 42.0; groups["Vandal"].target_lon = -1.0 
        groups["Alan"].target_lat = 42.0; groups["Alan"].target_lon = -1.0
    end
    
    if year == 410
        event_text[] = "Event: Vandals/Alans in Spain"
        groups["Vandal"].target_lat = 41.0; groups["Vandal"].target_lon = -6.0
        groups["Alan"].target_lat = 41.0; groups["Alan"].target_lon = -6.0
    end
    
    if year == 418
        event_text[] = "Event: Alans crushed (Deleted)"
        groups["Alan"].active = false 
        empty!(groups["Alan"].particles[])
        notify(groups["Alan"].particles)
        groups["Visigoth"].target_lat = 43.6; groups["Visigoth"].target_lon = 1.4 
    end
    
    # --- VANDAL RAILROAD ---
    if year >= 418 && year <= 440
        if haskey(groups, "Vandal")
            res = get_vandal_pos(year)
            if res !== nothing
                new_lat, new_lon = res
                groups["Vandal"].center_lat = new_lat
                groups["Vandal"].center_lon = new_lon
                groups["Vandal"].target_lat = new_lat
                groups["Vandal"].target_lon = new_lon
            end
            if year == 429 event_text[] = "Event: Vandals cross Gibraltar" end
            if year == 439 event_text[] = "Event: Vandals take Carthage" end
        end
    end

    if year == 480
        event_text[] = "Event: Franks (Clovis) take France"
        groups["Frank"] = create_group("Frank", :purple, 50.0, 6.0, 150)
        c_lift = lift((c, o) -> (c, o), groups["Frank"].color, groups["Frank"].opacity)
        scatter!(ax, groups["Frank"].particles, color=c_lift, markersize=6)
        groups["Frank"].target_lat = 48.0; groups["Frank"].target_lon = 2.0 
    end

    # SLAVS
    if year == 500
        event_text[] = "Event: Slavic Expansion"
        groups["Slav"].target_lat = 48.0; groups["Slav"].target_lon = 20.0
    end
    
    if year == 507
        event_text[] = "Event: Visigoths pushed to Spain (Toledo)"
        groups["Visigoth"].target_lat = 39.8; groups["Visigoth"].target_lon = -4.0 
    end
    
    if year == 600
        groups["SlavSouth"] = create_group("SlavSouth", :violet, 48.0, 20.0, 150)
        c_lift = lift((c, o) -> (c, o), groups["SlavSouth"].color, groups["SlavSouth"].opacity)
        scatter!(ax, groups["SlavSouth"].particles, color=c_lift, markersize=6)
        groups["SlavSouth"].target_lat = 42.0; groups["SlavSouth"].target_lon = 22.0
        
        if haskey(groups, "Vandal") groups["Vandal"].opacity[] = 0.35 end
        if haskey(groups, "Alan") groups["Alan"].opacity[] = 0.35 end
    end
    
    return should_pause
end

# --- 7. PHYSICS ENGINE ---
function step_physics!(g::CultureGroup, speed_per_year, dt)
    lat_diff = g.target_lat - g.center_lat
    lon_diff = g.target_lon - g.center_lon
    
    effective_speed = speed_per_year * g.mobility * dt 
    
    g.center_lat += lat_diff * effective_speed
    g.center_lon += lon_diff * effective_speed
    
    merc_center = latlon_to_mercator(g.center_lat, g.center_lon)
    
    if !g.active || isempty(g.particles[]) return end

    current_pts = g.particles[]
    new_pts = map(current_pts) do p
        v = merc_center - p
        noise = Point2f(randn()*40000, randn()*40000)
        p + (v * 0.3) + noise 
    end
    g.particles[] = new_pts
end


# --- 8. RUN LOOP ---
println("Starting Final Render (De-Conflicted)...")

t_pause = fill(-4000, 120)
t1 = -4000:5:-500 
t2 = -495:5:0
t3 = 1:1:600      
t4 = 605:5:1000

years = vcat(t_pause, t1, t2, t3, t4)
deltas = vcat(fill(0, 120), fill(5, length(t1)), fill(5, length(t2)), fill(1, length(t3)), fill(5, length(t4)))

video_stream = VideoStream(f, framerate=24)

for i in 1:length(years)
    year = years[i]
    dt = deltas[i]
    
    clear_zone()
    
    suffix = year < 0 ? "BCE" : "CE"
    year_text[] = "Year: $(abs(year)) $suffix"
    
    pause_needed = update_history!(groups, year, ax)
    
    for g in values(groups)
        if g.active
            step_physics!(g, 0.008, dt)
        end
    end
    
    recordframe!(video_stream)
    
    if pause_needed
        for _ in 1:48 
            recordframe!(video_stream)
        end
        clear_zone()
        recordframe!(video_stream)
    end
end

save("indo_european_spread.mp4", video_stream)
println("Done! Saved indo_european_spread.mp4")