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")
