Files
Starlight_Lancher/apps/app-frontend/src/pages/LabSeedMap.vue
T
Disy d2a319a4d5
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
fix: 启用启动器更新并修复初始化竞态
2026-09-26 13:06:51 +08:00

3989 lines
121 KiB
Vue

<script setup lang="ts">
import {
ChevronDownIcon,
CircleAlertIcon,
ClipboardCopyIcon,
CompassIcon,
ContractIcon,
ExpandIcon,
EyeIcon,
EyeOffIcon,
GridIcon,
HashIcon,
HistoryIcon,
ImportIcon,
LandmarkIcon,
LayersIcon,
MinusIcon,
PickaxeIcon,
PinIcon,
PlusIcon,
RefreshCwIcon,
ScaleIcon,
SearchIcon,
SettingsIcon,
ShareIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
Checkbox,
defineMessages,
DropdownSelect,
injectNotificationManager,
type MessageDescriptor,
PopoutMenu,
Slider,
StyledInput,
Toggle,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import {
computed,
nextTick,
onMounted,
onUnmounted,
reactive,
ref,
useTemplateRef,
watch,
} from 'vue'
import { useRoute } from 'vue-router'
import SeedMapBiomePicker from '@/components/lab/seed-map/SeedMapBiomePicker.vue'
import SeedMapCopyrightModal from '@/components/lab/seed-map/SeedMapCopyrightModal.vue'
import SeedMapWorldImportModal, {
type SeedMapWorldImport,
} from '@/components/lab/seed-map/SeedMapWorldImportModal.vue'
import {
applyShareQuery,
clearSeedMapHistory,
createDefaultSeedMapWorkspace,
createShareQuery,
fallbackSeedMapProfiles,
featureKey,
featureMask,
findSeedMapFeatures,
getSeedMapBiomeAt,
getSeedMapProfiles,
getSeedMapSpawn,
isCurrentSeedMapEpoch,
loadSeedMapHistory,
loadSeedMapWorkspace,
recordSeedMapHistory,
removeSeedMapHistoryEntry,
renderSeedMapTile,
saveSeedMapWorkspace,
SEED_MAP_BIOME_NAMES,
SEED_MAP_BIOMES,
SEED_MAP_ELEVATIONS,
SEED_MAP_END_CITY_IMAGE_SOURCES as endCityImageSources,
SEED_MAP_FEATURE_COLORS as featureColors,
SEED_MAP_FEATURE_ICONS as featureIcons,
SEED_MAP_FEATURE_IMAGE_SOURCES as featureImageSources,
SEED_MAP_FEATURES,
SEED_MAP_MIN_ZOOM,
SEED_MAP_ORE_MAX_SCALE,
SEED_MAP_SCALES,
SEED_MAP_STRUCTURE_ASSET_ROOT as structureAssetRoot,
seedMapBiomeSlug,
type SeedMapDimension,
type SeedMapDisplayMode,
type SeedMapEdition,
type SeedMapFeature,
type SeedMapFeatureKind,
type SeedMapHistoryEntry,
seedMapHistoryId,
type SeedMapHistorySource,
type SeedMapMarker,
seedMapOreDefinition,
type SeedMapOreHit,
seedMapOreKey,
type SeedMapOreKind,
seedMapOresForDimension,
seedMapOreYRange,
type SeedMapSpawnPoint,
seedMapTileConcurrency,
type SeedMapTileRequest,
type SeedMapVersionProfile,
updateSeedMapHistoryProgress,
useSeedMapOreLayer,
visibleFeatureDefinitions,
} from '@/lab/seed-map'
type MapBounds = {
minX: number
minZ: number
maxX: number
maxZ: number
}
type Selection = {
x: number
z: number
feature?: SeedMapFeature
ore?: SeedMapOreHit
markerId?: string
spawn?: boolean
biome?: number
}
type TileJob = {
epoch: number
key: string
run: () => Promise<void>
}
type ActivePointer = {
x: number
y: number
}
// Keep one extra source tile around each edge so a fractional zoom/pan does
// not make an edge tile disappear when a bounds value crosses a tile boundary.
const TILE_CULL_PADDING = 1
type SeedMapElevation = (typeof SEED_MAP_ELEVATIONS)[number]
const { addNotification, handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const route = useRoute()
const canvas = useTemplateRef<HTMLCanvasElement>('mapCanvas')
const fullscreenContainer = useTemplateRef<HTMLElement>('fullscreenContainer')
const workspace = reactive(applyShareQuery(route.query, loadSeedMapWorkspace()))
if (!SEED_MAP_ELEVATIONS.includes(workspace.elevation as SeedMapElevation))
workspace.elevation = SEED_MAP_ELEVATIONS[0]
const profiles = ref<SeedMapVersionProfile[]>(fallbackSeedMapProfiles())
const features = ref<SeedMapFeature[]>([])
const spawn = ref<SeedMapSpawnPoint | null>(null)
const selection = ref<Selection | null>(null)
const markerName = ref('')
const markerColor = ref('#22C55E')
const markerDraftOpen = ref(false)
const advancedOpen = ref(false)
const layersExpanded = ref(false)
const showLayerNames = ref(false)
const rulerPoints = ref<{ x: number; z: number }[]>([])
const rulerEnabled = ref(false)
const isFullscreen = ref(false)
const loading = ref(false)
const mapError = ref('')
const historyEntries = ref<SeedMapHistoryEntry[]>(loadSeedMapHistory())
const worldImportModal =
useTemplateRef<InstanceType<typeof SeedMapWorldImportModal>>('worldImportModal')
const copyrightModal = useTemplateRef<InstanceType<typeof SeedMapCopyrightModal>>('copyrightModal')
let activeHistoryId = seedMapHistoryId(workspace.seed.trim().slice(0, 256), workspace.edition)
const coordinateX = ref(String(Math.round(workspace.center.x)))
const coordinateZ = ref(String(Math.round(workspace.center.z)))
const tileImages = new Map<string, ImageBitmap>()
const featureImages = new Map<string, HTMLImageElement>()
const oreImages = new Map<string, HTMLImageElement>()
let spawnImage: HTMLImageElement | undefined
const pendingTiles = new Map<string, number>()
const tileQueue: TileJob[] = []
const activePointers = new Map<number, ActivePointer>()
const pointer = reactive({ id: -1, startX: 0, startY: 0, centerX: 0, centerZ: 0, moved: false })
const viewport = reactive({ width: 0, height: 0 })
let activeTileRequests = 0
const activeTileRequestsByEpoch = new Map<number, number>()
let requestEpoch = 0
let refreshTimer: ReturnType<typeof window.setTimeout> | undefined
let saveTimer: ReturnType<typeof window.setTimeout> | undefined
let longPressTimer: ReturnType<typeof window.setTimeout> | undefined
let redrawFrame: number | undefined
let panFrame: number | undefined
let pendingPanCenter: { x: number; z: number } | undefined
let zoomAnimationFrame: number | undefined
let fallbackTileScale: number | undefined
let resizeObserver: ResizeObserver | undefined
let pinchDistance = 0
let wheelMode: 'trackpad' | 'wheel' | undefined
let wheelGestureStartedAt = 0
let wheelDelta = 0
let wheelTimer: ReturnType<typeof window.setTimeout> | undefined
let trackpadTimer: ReturnType<typeof window.setTimeout> | undefined
let wheelAnchor = { x: 0, y: 0 }
let suppressPointerSelection = false
let centerOnNextSpawn = false
let spawnContextKey = ''
let pendingHistorySource: SeedMapHistorySource | null = null
let pendingHistoryLabels: { instanceName?: string; worldName?: string } = {}
let historyTimer: ReturnType<typeof window.setTimeout> | undefined
let progressTimer: ReturnType<typeof window.setTimeout> | undefined
let applyingSharedState = false
let biomeLookupToken = 0
const formatRelativeTime = useRelativeTime()
const {
hits: oreHits,
scanning: oreScanning,
scannedChunks: oreScannedChunks,
totalChunks: oreTotalChunks,
refresh: refreshOreLayer,
refreshFilter: refreshOreFilter,
dispose: disposeOreLayer,
} = useSeedMapOreLayer({
onUpdate: () => redraw(),
onError: () => {
mapError.value = formatMessage(messages.oreScanFailed)
},
})
const messages = defineMessages({
title: { id: 'app.lab.seed-map.title', defaultMessage: 'Seed map' },
seed: { id: 'app.lab.seed-map.seed', defaultMessage: 'Seed' },
seedPlaceholder: {
id: 'app.lab.seed-map.seed-placeholder',
defaultMessage: 'Enter a number or text seed',
},
randomSeed: { id: 'app.lab.seed-map.random-seed', defaultMessage: 'Random seed' },
importFromInstance: {
id: 'app.lab.seed-map.import-from-instance',
defaultMessage: 'Load from instance',
},
worldSeedLoaded: {
id: 'app.lab.seed-map.world-seed-loaded',
defaultMessage: 'Loaded the seed of {world}',
},
history: { id: 'app.lab.seed-map.history', defaultMessage: 'Seed history' },
historyEmpty: {
id: 'app.lab.seed-map.history-empty',
defaultMessage: 'Seeds you view will appear here',
},
clearHistory: { id: 'app.lab.seed-map.clear-history', defaultMessage: 'Clear history' },
removeHistoryEntry: {
id: 'app.lab.seed-map.remove-history-entry',
defaultMessage: 'Remove from history',
},
historySourceManual: {
id: 'app.lab.seed-map.history-source.manual',
defaultMessage: 'Entered manually',
},
historySourceRandom: {
id: 'app.lab.seed-map.history-source.random',
defaultMessage: 'Random seed',
},
historySourceShare: {
id: 'app.lab.seed-map.history-source.share',
defaultMessage: 'Shared link',
},
edition: { id: 'app.lab.seed-map.edition', defaultMessage: 'Edition' },
java: { id: 'app.lab.seed-map.edition.java', defaultMessage: 'Java Edition' },
javaLargeBiomes: {
id: 'app.lab.seed-map.edition.java-large-biomes',
defaultMessage: 'Java: Large Biomes',
},
gameVersion: { id: 'app.lab.seed-map.version', defaultMessage: 'Game version' },
dimension: { id: 'app.lab.seed-map.dimension', defaultMessage: 'Dimension' },
overworld: { id: 'app.lab.seed-map.dimension.overworld', defaultMessage: 'Overworld' },
nether: { id: 'app.lab.seed-map.dimension.nether', defaultMessage: 'Nether' },
end: { id: 'app.lab.seed-map.dimension.end', defaultMessage: 'The End' },
coordinates: { id: 'app.lab.seed-map.coordinates', defaultMessage: 'Coordinates' },
surface: { id: 'app.lab.seed-map.elevation.surface', defaultMessage: 'Surface' },
shallowUnderground: {
id: 'app.lab.seed-map.elevation.shallow-underground',
defaultMessage: 'Shallow underground',
},
deepUnderground: {
id: 'app.lab.seed-map.elevation.deep-underground',
defaultMessage: 'Deep underground',
},
go: { id: 'app.lab.seed-map.go', defaultMessage: 'Go' },
layers: { id: 'app.lab.seed-map.layers', defaultMessage: 'Map layers' },
structures: { id: 'app.lab.seed-map.mode.structures', defaultMessage: 'Structures' },
ores: { id: 'app.lab.seed-map.mode.ores', defaultMessage: 'Ores' },
oreLayers: { id: 'app.lab.seed-map.ore-layers', defaultMessage: 'Ore layers' },
zoomToScanOres: {
id: 'app.lab.seed-map.zoom-to-scan-ores',
defaultMessage: 'Zoom in to scan ores',
},
noOresInEnd: {
id: 'app.lab.seed-map.no-ores-in-end',
defaultMessage: 'Ore scanning is not available in The End',
},
oresRequireModern: {
id: 'app.lab.seed-map.ores-require-modern',
defaultMessage: 'Ore prediction requires Java 1.18 or newer',
},
oreYRange: { id: 'app.lab.seed-map.ore-y-range', defaultMessage: 'Y range' },
oreMinY: { id: 'app.lab.seed-map.ore-min-y', defaultMessage: 'Minimum Y' },
oreMaxY: { id: 'app.lab.seed-map.ore-max-y', defaultMessage: 'Maximum Y' },
resetYRange: { id: 'app.lab.seed-map.reset-y-range', defaultMessage: 'Reset Y range' },
scanningOres: {
id: 'app.lab.seed-map.scanning-ores',
defaultMessage: 'Scanning ores {processed}/{total}',
},
oreScanFailed: {
id: 'app.lab.seed-map.ore-scan-failed',
defaultMessage: 'Ore scanning failed. Try refreshing the map.',
},
oreVerified: { id: 'app.lab.seed-map.ore-verified', defaultMessage: 'Verified' },
oreLikely: { id: 'app.lab.seed-map.ore-likely', defaultMessage: 'Likely' },
orePrecision: {
id: 'app.lab.seed-map.ore-precision',
defaultMessage: '{precision}% precision',
},
oreCoverage: {
id: 'app.lab.seed-map.ore-coverage',
defaultMessage: 'Y {min} to {max}',
},
mined: { id: 'app.lab.seed-map.mined', defaultMessage: 'Mined' },
expandLayers: { id: 'app.lab.seed-map.expand-layers', defaultMessage: 'Expand layers' },
collapseLayers: { id: 'app.lab.seed-map.collapse-layers', defaultMessage: 'Collapse layers' },
showNames: { id: 'app.lab.seed-map.show-layer-names', defaultMessage: 'Show layer names' },
hideNames: { id: 'app.lab.seed-map.hide-layer-names', defaultMessage: 'Hide layer names' },
selectAll: { id: 'app.lab.seed-map.select-all', defaultMessage: 'Select all' },
clear: { id: 'app.lab.seed-map.clear', defaultMessage: 'Clear' },
reset: { id: 'app.lab.seed-map.reset', defaultMessage: 'Reset' },
spawnPoint: { id: 'app.lab.seed-map.spawn-point', defaultMessage: 'Spawn point' },
zoomToShow: { id: 'app.lab.seed-map.zoom-to-show', defaultMessage: 'Zoom in to show {layer}' },
mapSettings: { id: 'app.lab.seed-map.settings', defaultMessage: 'Advanced settings' },
showGrid: { id: 'app.lab.seed-map.show-grid', defaultMessage: 'Grid' },
chunkCoordinates: {
id: 'app.lab.seed-map.chunk-coordinates',
defaultMessage: 'Chunk coordinates',
},
depth: { id: 'app.lab.seed-map.depth', defaultMessage: 'Map depth' },
terrain: { id: 'app.lab.seed-map.terrain', defaultMessage: 'Terrain' },
contours: { id: 'app.lab.seed-map.contours', defaultMessage: 'Contour lines' },
zoomIn: { id: 'app.lab.seed-map.zoom-in', defaultMessage: 'Zoom in' },
zoomOut: { id: 'app.lab.seed-map.zoom-out', defaultMessage: 'Zoom out' },
fullscreen: { id: 'app.lab.seed-map.fullscreen', defaultMessage: 'Fullscreen' },
exitFullscreen: { id: 'app.lab.seed-map.exit-fullscreen', defaultMessage: 'Exit fullscreen' },
centerSpawn: { id: 'app.lab.seed-map.center-spawn', defaultMessage: 'Center spawn' },
searchNearby: { id: 'app.lab.seed-map.search-nearby', defaultMessage: 'Nearest structure' },
location: { id: 'app.lab.seed-map.location', defaultMessage: 'Location' },
biomeTag: { id: 'app.lab.seed-map.biome-tag', defaultMessage: 'Biome' },
copyTeleport: { id: 'app.lab.seed-map.copy-teleport', defaultMessage: 'Copy /tp' },
teleportCopied: {
id: 'app.lab.seed-map.teleport-copied',
defaultMessage: 'Teleport command copied',
},
addMarker: { id: 'app.lab.seed-map.add-marker', defaultMessage: 'Add marker' },
markerName: { id: 'app.lab.seed-map.marker-name', defaultMessage: 'Marker name' },
markerColor: { id: 'app.lab.seed-map.marker-color', defaultMessage: 'Marker color' },
markerSaved: { id: 'app.lab.seed-map.marker-saved', defaultMessage: 'Marker saved' },
markers: { id: 'app.lab.seed-map.markers', defaultMessage: 'My markers' },
noMarkers: { id: 'app.lab.seed-map.no-markers', defaultMessage: 'No saved markers' },
removeMarker: { id: 'app.lab.seed-map.remove-marker', defaultMessage: 'Remove marker' },
completed: { id: 'app.lab.seed-map.completed', defaultMessage: 'Completed' },
ruler: { id: 'app.lab.seed-map.ruler', defaultMessage: 'Measure distance' },
distance: { id: 'app.lab.seed-map.distance', defaultMessage: '{distance} blocks' },
clearRuler: { id: 'app.lab.seed-map.clear-ruler', defaultMessage: 'Clear measurement' },
share: { id: 'app.lab.seed-map.share', defaultMessage: 'Share' },
shareCopied: { id: 'app.lab.seed-map.share-copied', defaultMessage: 'Map link copied' },
approximate: { id: 'app.lab.seed-map.approximate', defaultMessage: 'Estimated' },
mapScale: { id: 'app.lab.seed-map.map-scale', defaultMessage: '1 px = {scale} blocks' },
copyright: {
id: 'app.lab.seed-map.copyright.open',
defaultMessage: 'Copyright and attribution',
},
loading: { id: 'app.lab.seed-map.loading', defaultMessage: 'Loading map' },
mapFailed: { id: 'app.lab.seed-map.map-failed', defaultMessage: 'Map generation failed' },
closePanel: { id: 'app.lab.seed-map.close-panel', defaultMessage: 'Close panel' },
village: { id: 'app.lab.seed-map.feature.village', defaultMessage: 'Village' },
outpost: { id: 'app.lab.seed-map.feature.outpost', defaultMessage: 'Pillager outpost' },
shipwreck: { id: 'app.lab.seed-map.feature.shipwreck', defaultMessage: 'Shipwreck' },
monument: { id: 'app.lab.seed-map.feature.monument', defaultMessage: 'Ocean monument' },
mansion: { id: 'app.lab.seed-map.feature.mansion', defaultMessage: 'Woodland mansion' },
ancientCity: { id: 'app.lab.seed-map.feature.ancient-city', defaultMessage: 'Ancient city' },
trailRuins: { id: 'app.lab.seed-map.feature.trail-ruins', defaultMessage: 'Trail ruins' },
trialChambers: {
id: 'app.lab.seed-map.feature.trial-chambers',
defaultMessage: 'Trial chambers',
},
ruinedPortal: { id: 'app.lab.seed-map.feature.ruined-portal', defaultMessage: 'Ruined portal' },
stronghold: { id: 'app.lab.seed-map.feature.stronghold', defaultMessage: 'Stronghold' },
slimeChunk: { id: 'app.lab.seed-map.feature.slime-chunk', defaultMessage: 'Slime chunks' },
desertPyramid: {
id: 'app.lab.seed-map.feature.desert-pyramid',
defaultMessage: 'Desert pyramid',
},
jungleTemple: { id: 'app.lab.seed-map.feature.jungle-temple', defaultMessage: 'Jungle temple' },
swampHut: { id: 'app.lab.seed-map.feature.swamp-hut', defaultMessage: 'Swamp hut' },
igloo: { id: 'app.lab.seed-map.feature.igloo', defaultMessage: 'Igloo' },
oceanRuin: { id: 'app.lab.seed-map.feature.ocean-ruin', defaultMessage: 'Ocean ruin' },
buriedTreasure: {
id: 'app.lab.seed-map.feature.buried-treasure',
defaultMessage: 'Buried treasure',
},
mineshaft: { id: 'app.lab.seed-map.feature.mineshaft', defaultMessage: 'Mineshaft' },
desertWell: { id: 'app.lab.seed-map.feature.desert-well', defaultMessage: 'Desert well' },
geode: { id: 'app.lab.seed-map.feature.geode', defaultMessage: 'Amethyst geode' },
fortress: { id: 'app.lab.seed-map.feature.fortress', defaultMessage: 'Nether fortress' },
bastion: { id: 'app.lab.seed-map.feature.bastion', defaultMessage: 'Bastion remnant' },
endCity: { id: 'app.lab.seed-map.feature.end-city', defaultMessage: 'End city' },
endCityWithShip: {
id: 'app.lab.seed-map.feature.end-city-with-ship',
defaultMessage: 'End city (with ship)',
},
endCityWithoutShip: {
id: 'app.lab.seed-map.feature.end-city-without-ship',
defaultMessage: 'End city (without ship)',
},
endGateway: { id: 'app.lab.seed-map.feature.end-gateway', defaultMessage: 'End gateway' },
oreDiamond: { id: 'app.lab.seed-map.ore.diamond', defaultMessage: 'Diamond ore' },
oreNetherite: { id: 'app.lab.seed-map.ore.netherite', defaultMessage: 'Ancient debris' },
oreIron: { id: 'app.lab.seed-map.ore.iron', defaultMessage: 'Iron ore' },
oreIronVein: { id: 'app.lab.seed-map.ore.iron-vein', defaultMessage: 'Iron vein' },
oreCopper: { id: 'app.lab.seed-map.ore.copper', defaultMessage: 'Copper ore' },
oreCopperVein: { id: 'app.lab.seed-map.ore.copper-vein', defaultMessage: 'Copper vein' },
oreGold: { id: 'app.lab.seed-map.ore.gold', defaultMessage: 'Gold ore' },
oreRedstone: { id: 'app.lab.seed-map.ore.redstone', defaultMessage: 'Redstone ore' },
oreLapis: { id: 'app.lab.seed-map.ore.lapis', defaultMessage: 'Lapis lazuli ore' },
oreCoal: { id: 'app.lab.seed-map.ore.coal', defaultMessage: 'Coal ore' },
})
const featureMessages: Record<SeedMapFeatureKind, (typeof messages)[keyof typeof messages]> = {
village: messages.village,
outpost: messages.outpost,
shipwreck: messages.shipwreck,
monument: messages.monument,
mansion: messages.mansion,
'ancient-city': messages.ancientCity,
'trail-ruins': messages.trailRuins,
'trial-chambers': messages.trialChambers,
'ruined-portal': messages.ruinedPortal,
stronghold: messages.stronghold,
'slime-chunk': messages.slimeChunk,
'desert-pyramid': messages.desertPyramid,
'jungle-temple': messages.jungleTemple,
'swamp-hut': messages.swampHut,
igloo: messages.igloo,
'ocean-ruin': messages.oceanRuin,
'buried-treasure': messages.buriedTreasure,
mineshaft: messages.mineshaft,
'desert-well': messages.desertWell,
geode: messages.geode,
fortress: messages.fortress,
bastion: messages.bastion,
'end-city': messages.endCity,
'end-gateway': messages.endGateway,
}
const oreMessages: Record<SeedMapOreKind, (typeof messages)[keyof typeof messages]> = {
diamond: messages.oreDiamond,
netherite: messages.oreNetherite,
iron: messages.oreIron,
iron_vein: messages.oreIronVein,
copper: messages.oreCopper,
copper_vein: messages.oreCopperVein,
gold: messages.oreGold,
redstone: messages.oreRedstone,
lapis: messages.oreLapis,
coal: messages.oreCoal,
}
const editionOptions: SeedMapEdition[] = ['java', 'java-large-biomes']
const elevationOptions: SeedMapElevation[] = [...SEED_MAP_ELEVATIONS]
const elevationLabels: Record<SeedMapElevation, MessageDescriptor> = {
128: messages.surface,
16: messages.shallowUnderground,
'-32': messages.deepUnderground,
}
function elevationLabel(elevation: SeedMapElevation) {
return `${formatMessage(elevationLabels[elevation])} (Y=${elevation})`
}
function setElevation(value: unknown) {
const elevation = Number(value) as SeedMapElevation
if (elevationOptions.includes(elevation)) workspace.elevation = elevation
}
const selectedProfile = computed(() =>
profiles.value.find(
(profile) => profile.edition === workspace.edition && profile.version === workspace.gameVersion,
),
)
const availableVersions = computed(() =>
profiles.value
.filter((profile) => profile.edition === workspace.edition)
.map((profile) => profile.version),
)
const dimensions = computed(
() => selectedProfile.value?.dimensions ?? ['overworld', 'nether', 'end'],
)
const scale = computed(() => 4 ** workspace.zoom)
// Native tiles are generated at discrete powers of four. Keep the current
// tile source for the whole zoom interval and switch only at its boundary;
// rounding here used to replace the biome sampler halfway through an
// animated zoom (for example at zoom 0.5), causing abrupt color changes.
const tileScale = computed(
() =>
SEED_MAP_SCALES[Math.min(Math.max(Math.floor(workspace.zoom), 0), SEED_MAP_SCALES.length - 1)],
)
const mapScaleLabel = computed(() =>
scale.value >= 10 ? String(Math.round(scale.value)) : scale.value.toFixed(2).replace(/\.00$/, ''),
)
const dimensionFeatures = computed(() =>
SEED_MAP_FEATURES.filter((feature) => feature.dimensions.includes(workspace.dimension)),
)
const requestedFeatures = computed(() =>
visibleFeatureDefinitions(workspace.visibleFeatures, workspace.dimension, tileScale.value),
)
const requestedFeatureKinds = computed(
() => new Set(requestedFeatures.value.map((feature) => feature.kind)),
)
const displayedFeatures = computed(() =>
features.value.filter((feature) => requestedFeatureKinds.value.has(feature.kind)),
)
const activeFeatureMask = computed(() =>
featureMask(requestedFeatures.value.map((feature) => feature.kind)),
)
const oreModeSupported = computed(() => selectedProfile.value?.ores !== false)
const dimensionOres = computed(() =>
oreModeSupported.value ? seedMapOresForDimension(workspace.dimension) : [],
)
const activeSelectedOres = computed(() => {
const available = new Set(dimensionOres.value.map((ore) => ore.kind))
return workspace.selectedOres.filter((ore) => available.has(ore))
})
const activeOreYRange = computed(() => seedMapOreYRange(activeSelectedOres.value))
const oreYMinimum = computed({
get: () => workspace.oreYMin ?? activeOreYRange.value[0],
set: (value: number) => {
workspace.oreYMin = Math.min(Math.round(value), workspace.oreYMax ?? activeOreYRange.value[1])
},
})
const oreYMaximum = computed({
get: () => workspace.oreYMax ?? activeOreYRange.value[1],
set: (value: number) => {
workspace.oreYMax = Math.max(Math.round(value), workspace.oreYMin ?? activeOreYRange.value[0])
},
})
const oreZoomLimited = computed(
() => workspace.displayMode === 'ores' && scale.value > SEED_MAP_ORE_MAX_SCALE,
)
const mapBusy = computed(() => loading.value || oreScanning.value)
const availableBiomes = computed(() =>
SEED_MAP_BIOMES.filter((biome) => biome.dimensions.includes(workspace.dimension)).map(
(biome) => biome.id,
),
)
const activeHighlightedBiomes = computed(() =>
workspace.highlightedBiomes.filter((biome) => availableBiomes.value.includes(biome)),
)
const terrainSupported = computed(() => workspace.dimension !== 'nether')
const terrainEnabled = computed({
get: () => terrainSupported.value && workspace.terrainEstimation,
set: (enabled: boolean) => {
if (!terrainSupported.value) return
workspace.terrainEstimation = enabled
if (!enabled) workspace.contourLines = false
},
})
const contoursEnabled = computed({
get: () => workspace.contourLines,
set: (enabled: boolean) => {
workspace.contourLines = enabled
if (enabled) workspace.terrainEstimation = true
},
})
const selectedFeatureKey = computed(() =>
selection.value?.feature ? featureKey(selection.value.feature) : null,
)
const selectedFeatureCompleted = computed({
get: () => {
const key = selectedFeatureKey.value
return key !== null && workspace.completedFeatures.includes(key)
},
set: (completed: boolean) => {
const key = selectedFeatureKey.value
if (!key) return
workspace.completedFeatures = completed
? [...new Set([...workspace.completedFeatures, key])]
: workspace.completedFeatures.filter((item) => item !== key)
},
})
const selectedOreKey = computed(() =>
selection.value?.ore ? seedMapOreKey(selection.value.ore) : null,
)
const selectedOreCompleted = computed({
get: () => {
const key = selectedOreKey.value
return key !== null && workspace.completedOres.includes(key)
},
set: (completed: boolean) => {
const key = selectedOreKey.value
if (!key) return
workspace.completedOres = completed
? [...new Set([...workspace.completedOres, key])]
: workspace.completedOres.filter((item) => item !== key)
},
})
const selectedMarker = computed(() =>
selection.value?.markerId
? (workspace.markers.find((marker) => marker.id === selection.value?.markerId) ?? null)
: null,
)
const rulerDistance = computed(() => {
if (rulerPoints.value.length !== 2) return null
const [start, end] = rulerPoints.value
return Math.round(Math.hypot(end.x - start.x, end.z - start.z))
})
const selectionStyle = computed(() => {
if (!selection.value) return undefined
const point = worldToScreen(selection.value.x, selection.value.z)
return {
left: `${Math.min(Math.max(point.x + 14, 8), Math.max(viewport.width - 286, 8))}px`,
top: `${Math.min(Math.max(point.y + 14, 8), Math.max(viewport.height - 226, 8))}px`,
}
})
const selectionVisible = computed(() => {
if (!selection.value) return false
if (selection.value.feature && !requestedFeatureKinds.value.has(selection.value.feature.kind))
return false
if (selection.value.ore && workspace.displayMode !== 'ores') return false
const point = worldToScreen(selection.value.x, selection.value.z)
return (
point.x >= -24 &&
point.x <= viewport.width + 24 &&
point.y >= -24 &&
point.y <= viewport.height + 24
)
})
watch(
workspace,
() => {
if (saveTimer) window.clearTimeout(saveTimer)
saveTimer = window.setTimeout(() => saveSeedMapWorkspace(workspace), 350)
},
{ deep: true },
)
watch(
() => workspace.seed,
(seed, previousSeed) => {
if (seed === previousSeed) return
stashActiveProgress()
activeHistoryId = seedMapHistoryId(seed.trim().slice(0, 256), workspace.edition)
const entry = historyEntries.value.find((item) => item.id === activeHistoryId)
workspace.completedFeatures = entry ? [...entry.completedFeatures] : []
workspace.completedOres = entry ? [...entry.completedOres] : []
const source = pendingHistorySource ?? 'manual'
const labels = pendingHistoryLabels
pendingHistorySource = null
pendingHistoryLabels = {}
if (historyTimer) window.clearTimeout(historyTimer)
if (seed.trim()) {
if (source === 'manual') {
historyTimer = window.setTimeout(() => {
historyTimer = undefined
recordHistory('manual')
}, 2_000)
} else {
recordHistory(source, labels)
}
}
if (applyingSharedState) {
spawn.value = null
spawnContextKey = ''
features.value = []
return
}
const defaults = createDefaultSeedMapWorkspace()
centerOnNextSpawn = true
workspace.dimension = 'overworld'
workspace.center = { x: 0, z: 0 }
workspace.zoom = defaults.zoom
workspace.showSpawn = true
workspace.visibleFeatures = [...defaults.visibleFeatures]
spawn.value = null
spawnContextKey = ''
features.value = []
},
)
watch(
() => route.query,
(query) => {
if (typeof query.seed !== 'string' || !query.seed.trim()) return
const shared = applyShareQuery(query, {
...createDefaultSeedMapWorkspace(),
markers: [...workspace.markers],
})
applyingSharedState = true
pendingHistorySource = 'share'
const seedChanged = workspace.seed !== shared.seed
workspace.edition = shared.edition
workspace.gameVersion = shared.gameVersion
workspace.dimension = shared.dimension
workspace.displayMode = shared.displayMode
workspace.selectedOres = [...shared.selectedOres]
workspace.oreYMin = shared.oreYMin
workspace.oreYMax = shared.oreYMax
workspace.zoom = shared.zoom
workspace.center = { ...shared.center }
workspace.seed = shared.seed
selection.value = null
centerOnNextSpawn = false
if (!seedChanged) {
pendingHistorySource = null
recordHistory('share')
}
void nextTick(() => {
applyingSharedState = false
scheduleRefresh()
})
},
)
watch(
() => [workspace.completedFeatures, workspace.completedOres],
() => {
if (progressTimer) window.clearTimeout(progressTimer)
progressTimer = window.setTimeout(() => {
progressTimer = undefined
stashActiveProgress()
}, 600)
},
{ deep: true },
)
watch(
() => [
workspace.seed,
workspace.edition,
workspace.gameVersion,
workspace.dimension,
workspace.displayMode,
tileScale.value,
workspace.elevation,
workspace.terrainEstimation,
workspace.contourLines,
workspace.highlightBiomeEnabled,
workspace.highlightedBiomes.join('|'),
workspace.showSpawn,
workspace.visibleFeatures.join('|'),
workspace.selectedOres.join('|'),
],
() => scheduleRefresh(),
{ flush: 'post' },
)
watch(
[
() => workspace.showGrid,
() => workspace.showChunkCoordinates,
() => workspace.markers,
() => workspace.completedFeatures,
() => workspace.completedOres,
showLayerNames,
rulerPoints,
],
() => redraw(),
{ deep: true, flush: 'post' },
)
watch(
() => [workspace.oreYMin, workspace.oreYMax] as const,
([yMin, yMax]) => refreshOreFilter(yMin, yMax),
{ flush: 'post' },
)
watch(
activeOreYRange,
([min, max]) => {
if (workspace.oreYMin !== null)
workspace.oreYMin = Math.min(max, Math.max(min, workspace.oreYMin))
if (workspace.oreYMax !== null)
workspace.oreYMax = Math.min(max, Math.max(min, workspace.oreYMax))
},
{ flush: 'post' },
)
watch(
() => workspace.edition,
() => {
const profile = profiles.value.find((item) => item.edition === workspace.edition)
if (!profile) return
workspace.gameVersion = profile.version
workspace.dimension = profile.dimensions.includes(workspace.dimension)
? workspace.dimension
: 'overworld'
},
)
watch(
() => workspace.gameVersion,
() => {
const profile = selectedProfile.value
if (profile && !profile.dimensions.includes(workspace.dimension))
workspace.dimension = 'overworld'
ensureDimensionOreSelection()
},
)
watch(oreModeSupported, (supported) => {
if (!supported && workspace.displayMode === 'ores') workspace.displayMode = 'structures'
})
watch(
() => workspace.dimension,
(dimension) => {
if (dimension !== 'overworld') centerOnNextSpawn = false
if (dimension === 'nether') workspace.contourLines = false
ensureDimensionOreSelection()
resetOreYRange()
selection.value = null
},
)
watch(
() => workspace.showSpawn,
(showSpawn) => {
if (!showSpawn) centerOnNextSpawn = false
},
)
watch(
() => [workspace.center.x, workspace.center.z],
([x, z]) => {
coordinateX.value = String(Math.round(x))
coordinateZ.value = String(Math.round(z))
},
)
onMounted(async () => {
await nextTick()
preloadMapIcons()
resizeCanvas()
resizeObserver = new ResizeObserver(resizeCanvas)
if (canvas.value) resizeObserver.observe(canvas.value)
document.addEventListener('fullscreenchange', onFullscreenChange)
profiles.value = await getSeedMapProfiles().catch(() => fallbackSeedMapProfiles())
ensureProfile()
if (typeof route.query.seed === 'string' && route.query.seed.trim()) {
recordHistory('share')
}
scheduleRefresh(0)
})
onUnmounted(() => {
if (historyTimer) {
window.clearTimeout(historyTimer)
historyTimer = undefined
recordHistory('manual')
}
if (progressTimer) {
window.clearTimeout(progressTimer)
progressTimer = undefined
}
stashActiveProgress()
if (refreshTimer) window.clearTimeout(refreshTimer)
if (saveTimer) window.clearTimeout(saveTimer)
if (longPressTimer) window.clearTimeout(longPressTimer)
if (wheelTimer) window.clearTimeout(wheelTimer)
if (trackpadTimer) window.clearTimeout(trackpadTimer)
if (redrawFrame !== undefined) window.cancelAnimationFrame(redrawFrame)
if (panFrame !== undefined) window.cancelAnimationFrame(panFrame)
if (zoomAnimationFrame !== undefined) window.cancelAnimationFrame(zoomAnimationFrame)
saveSeedMapWorkspace(workspace)
disposeOreLayer()
clearTileImages()
resizeObserver?.disconnect()
document.removeEventListener('fullscreenchange', onFullscreenChange)
})
function ensureProfile() {
if (selectedProfile.value) return
const replacement =
profiles.value.find((item) => item.edition === workspace.edition) ?? profiles.value[0]
if (!replacement) return
workspace.edition = replacement.edition
workspace.gameVersion = replacement.version
workspace.dimension = replacement.dimensions[0] ?? 'overworld'
}
function editionLabel(edition: SeedMapEdition) {
if (edition === 'java-large-biomes') return formatMessage(messages.javaLargeBiomes)
return formatMessage(messages.java)
}
function dimensionLabel(dimension: SeedMapDimension) {
return formatMessage(messages[dimension])
}
function featureLabel(kind: SeedMapFeatureKind) {
return formatMessage(featureMessages[kind])
}
function featureResultLabel(feature: SeedMapFeature) {
if (feature.kind !== 'end-city' || feature.endShip === undefined) {
return featureLabel(feature.kind)
}
return formatMessage(feature.endShip ? messages.endCityWithShip : messages.endCityWithoutShip)
}
function featureImageKey(feature: SeedMapFeature) {
if (feature.kind !== 'end-city' || feature.endShip === undefined) return feature.kind
return `end-city:${feature.endShip ? 'ship' : 'no-ship'}`
}
function oreLabel(kind: SeedMapOreKind) {
return formatMessage(oreMessages[kind])
}
function setDisplayMode(mode: SeedMapDisplayMode) {
if (mode === 'ores' && !oreModeSupported.value) return
if (mode === 'ores' && workspace.dimension === 'end') workspace.dimension = 'overworld'
workspace.displayMode = mode
ensureDimensionOreSelection()
selection.value = null
}
function ensureDimensionOreSelection() {
if (dimensionOres.value.length === 0 || activeSelectedOres.value.length > 0) return
const first = dimensionOres.value[0]?.kind
if (first) workspace.selectedOres = [...new Set([...workspace.selectedOres, first])]
}
function featureTooltip(kind: SeedMapFeatureKind, maxScale: number) {
const label = featureLabel(kind)
return tileScale.value > maxScale ? formatMessage(messages.zoomToShow, { layer: label }) : label
}
function featureImageSource(kind: SeedMapFeatureKind) {
return featureImageSources[kind]
}
function preloadMapIcons() {
const preloadFeatureImage = (key: string, source: string) => {
const image = new Image()
image.onload = () => {
featureImages.set(key, image)
redraw()
}
image.src = source
}
for (const [kind, source] of Object.entries(featureImageSources) as [
SeedMapFeatureKind,
string,
][]) {
preloadFeatureImage(kind, source)
}
preloadFeatureImage('end-city:ship', endCityImageSources.ship)
preloadFeatureImage('end-city:no-ship', endCityImageSources.noShip)
for (const ore of seedMapOresForDimension('overworld').concat(
seedMapOresForDimension('nether'),
)) {
for (const [variant, source] of [
['stone', ore.texture],
['deepslate', ore.deepslateTexture],
] as const) {
if (!source) continue
const image = new Image()
image.onload = () => {
oreImages.set(`${ore.kind}:${variant}`, image)
redraw()
}
image.src = source
}
}
const image = new Image()
image.onload = () => {
spawnImage = image
redraw()
}
image.src = `${structureAssetRoot}/spawn_point.webp`
}
function resizeCanvas() {
const element = canvas.value
if (!element) return
const bounds = element.getBoundingClientRect()
viewport.width = Math.max(1, Math.round(bounds.width))
viewport.height = Math.max(1, Math.round(bounds.height))
const ratio = window.devicePixelRatio || 1
element.width = Math.round(viewport.width * ratio)
element.height = Math.round(viewport.height * ratio)
element.getContext('2d')?.setTransform(ratio, 0, 0, ratio, 0, 0)
redraw()
scheduleRefresh()
}
function mapBounds(): MapBounds {
const halfWidth = (viewport.width * scale.value) / 2
const halfHeight = (viewport.height * scale.value) / 2
return {
minX: Math.floor(workspace.center.x - halfWidth),
minZ: Math.floor(workspace.center.z - halfHeight),
maxX: Math.ceil(workspace.center.x + halfWidth),
maxZ: Math.ceil(workspace.center.z + halfHeight),
}
}
/**
* Floating-point bounds used for canvas transforms. Tile/feature requests
* still use the integer bounds above, but rounding those bounds for drawing
* makes the map jump by a pixel while a zoom animation is in progress.
*/
function viewBounds() {
const halfWidth = (viewport.width * scale.value) / 2
const halfHeight = (viewport.height * scale.value) / 2
return {
minX: workspace.center.x - halfWidth,
minZ: workspace.center.z - halfHeight,
}
}
function worldToScreen(x: number, z: number) {
const bounds = viewBounds()
return { x: (x - bounds.minX) / scale.value, y: (z - bounds.minZ) / scale.value }
}
function screenToWorld(x: number, y: number) {
const bounds = viewBounds()
return {
x: Math.round(bounds.minX + x * scale.value),
z: Math.round(bounds.minZ + y * scale.value),
}
}
function tileKey(x: number, z: number, sourceScale = tileScale.value) {
return [
workspace.seed,
workspace.edition,
workspace.gameVersion,
workspace.dimension,
sourceScale,
workspace.elevation,
terrainEnabled.value,
workspace.contourLines,
workspace.highlightBiomeEnabled ? activeHighlightedBiomes.value.join(',') : 'none',
x,
z,
].join(':')
}
function redraw() {
if (redrawFrame !== undefined) return
redrawFrame = window.requestAnimationFrame(() => {
redrawFrame = undefined
drawMap()
})
}
function drawMap() {
const element = canvas.value
const context = element?.getContext('2d')
if (!element || !context || !viewport.width || !viewport.height) return
const style = getComputedStyle(element)
const surface2 = style.getPropertyValue('--surface-2').trim() || '#17191D'
const surface3 = style.getPropertyValue('--surface-3').trim() || '#23262D'
context.clearRect(0, 0, viewport.width, viewport.height)
context.fillStyle = surface2
context.fillRect(0, 0, viewport.width, viewport.height)
context.fillStyle = surface3
for (let y = 0; y < viewport.height; y += 32) {
for (let x = 0; x < viewport.width; x += 32) {
if ((x / 32 + y / 32) % 2 === 0) context.fillRect(x, y, 32, 32)
}
}
const bounds = mapBounds()
if (fallbackTileScale !== undefined && fallbackTileScale !== tileScale.value) {
drawTileLayer(context, bounds, fallbackTileScale)
}
drawTileLayer(context, bounds, tileScale.value)
if (workspace.showGrid || workspace.showChunkCoordinates) drawGrid(context, bounds)
if (workspace.displayMode === 'structures') drawFeatures(context)
else drawOres(context)
drawSpawn(context)
drawMarkers(context)
drawRuler(context)
}
function drawTileLayer(context: CanvasRenderingContext2D, bounds: MapBounds, sourceScale: number) {
const tileSpan = 256 * sourceScale
const minTileX = Math.floor(bounds.minX / tileSpan) - TILE_CULL_PADDING
const minTileZ = Math.floor(bounds.minZ / tileSpan) - TILE_CULL_PADDING
const maxTileX = Math.floor(bounds.maxX / tileSpan) + TILE_CULL_PADDING
const maxTileZ = Math.floor(bounds.maxZ / tileSpan) + TILE_CULL_PADDING
const displaySize = tileSpan / scale.value
context.save()
context.imageSmoothingEnabled = false
for (let tileX = minTileX; tileX <= maxTileX; tileX++) {
for (let tileZ = minTileZ; tileZ <= maxTileZ; tileZ++) {
const key = tileKey(tileX, tileZ, sourceScale)
const bitmap = tileImages.get(key)
const position = worldToScreen(tileX * tileSpan, tileZ * tileSpan)
if (bitmap) {
tileImages.delete(key)
tileImages.set(key, bitmap)
context.drawImage(bitmap, position.x, position.y, displaySize, displaySize)
}
}
}
context.restore()
}
function visibleTileKeys(bounds: MapBounds): Set<string> {
const keys = new Set<string>()
const sourceScales = new Set<number>([tileScale.value])
if (fallbackTileScale !== undefined) sourceScales.add(fallbackTileScale)
for (const sourceScale of sourceScales) {
const tileSpan = 256 * sourceScale
const minTileX = Math.floor(bounds.minX / tileSpan) - TILE_CULL_PADDING
const minTileZ = Math.floor(bounds.minZ / tileSpan) - TILE_CULL_PADDING
const maxTileX = Math.floor(bounds.maxX / tileSpan) + TILE_CULL_PADDING
const maxTileZ = Math.floor(bounds.maxZ / tileSpan) + TILE_CULL_PADDING
for (let tileX = minTileX; tileX <= maxTileX; tileX++) {
for (let tileZ = minTileZ; tileZ <= maxTileZ; tileZ++)
keys.add(tileKey(tileX, tileZ, sourceScale))
}
}
return keys
}
function hasMissingCurrentTiles(bounds: MapBounds): boolean {
return countMissingCurrentTiles(bounds).missing > 0
}
function countMissingCurrentTiles(bounds: MapBounds) {
const sourceScale = tileScale.value
const tileSpan = 256 * sourceScale
const minTileX = Math.floor(bounds.minX / tileSpan) - TILE_CULL_PADDING
const minTileZ = Math.floor(bounds.minZ / tileSpan) - TILE_CULL_PADDING
const maxTileX = Math.floor(bounds.maxX / tileSpan) + TILE_CULL_PADDING
const maxTileZ = Math.floor(bounds.maxZ / tileSpan) + TILE_CULL_PADDING
let visible = 0
let missing = 0
for (let tileX = minTileX; tileX <= maxTileX; tileX++) {
for (let tileZ = minTileZ; tileZ <= maxTileZ; tileZ++) {
visible++
if (!tileImages.has(tileKey(tileX, tileZ, sourceScale))) missing++
}
}
return { visible, missing }
}
function drawGrid(context: CanvasRenderingContext2D, bounds: MapBounds) {
const step = Math.max(16, 16 * Math.ceil(scale.value / 16))
const firstX = Math.floor(bounds.minX / step) * step
const firstZ = Math.floor(bounds.minZ / step) * step
context.save()
context.strokeStyle = 'rgba(255, 255, 255, 0.24)'
context.lineWidth = 1
context.font = '11px system-ui'
context.fillStyle = 'rgba(255, 255, 255, 0.84)'
for (let x = firstX; x <= bounds.maxX; x += step) {
const screen = worldToScreen(x, bounds.minZ)
context.beginPath()
context.moveTo(screen.x, 0)
context.lineTo(screen.x, viewport.height)
context.stroke()
if (workspace.showChunkCoordinates)
context.fillText(String(Math.floor(x / 16)), screen.x + 4, 14)
}
for (let z = firstZ; z <= bounds.maxZ; z += step) {
const screen = worldToScreen(bounds.minX, z)
context.beginPath()
context.moveTo(0, screen.y)
context.lineTo(viewport.width, screen.y)
context.stroke()
if (workspace.showChunkCoordinates)
context.fillText(String(Math.floor(z / 16)), 4, screen.y - 4)
}
context.restore()
}
function drawFeatures(context: CanvasRenderingContext2D) {
for (const feature of displayedFeatures.value) {
const point = worldToScreen(feature.x, feature.z)
if (
point.x < -18 ||
point.x > viewport.width + 18 ||
point.y < -18 ||
point.y > viewport.height + 18
)
continue
const selected = selectedFeatureKey.value === featureKey(feature)
const completed = workspace.completedFeatures.includes(featureKey(feature))
context.save()
context.globalAlpha = completed ? 0.48 : 1
if (feature.kind === 'slime-chunk') {
const size = Math.max(5, Math.min(16 / scale.value, 14))
context.fillStyle = featureColors[feature.kind]
context.strokeStyle = selected ? '#FFFFFF' : 'rgba(15, 18, 20, 0.86)'
context.lineWidth = selected ? 3 : 2
context.beginPath()
context.rect(point.x - size / 2, point.y - size / 2, size, size)
context.fill()
context.stroke()
} else {
const icon = featureImages.get(featureImageKey(feature))
if (icon) {
const size = selected ? 32 : 28
context.shadowColor = 'rgba(0, 0, 0, 0.48)'
context.shadowBlur = 5
context.shadowOffsetY = 2
context.drawImage(icon, point.x - size / 2, point.y - size / 2, size, size)
if (selected) {
context.shadowColor = 'transparent'
context.strokeStyle = '#FFFFFF'
context.lineWidth = 2
context.beginPath()
context.arc(point.x, point.y, size / 2 + 2, 0, Math.PI * 2)
context.stroke()
}
} else {
const radius = selected ? 10 : 8
context.fillStyle = featureColors[feature.kind]
context.strokeStyle = selected ? '#FFFFFF' : 'rgba(15, 18, 20, 0.86)'
context.lineWidth = selected ? 3 : 2
context.beginPath()
context.arc(point.x, point.y, radius, 0, Math.PI * 2)
context.fill()
context.stroke()
context.fillStyle = '#FFFFFF'
context.font = '700 9px system-ui'
context.textAlign = 'center'
context.textBaseline = 'middle'
context.fillText(featureLabel(feature.kind).slice(0, 1).toUpperCase(), point.x, point.y)
}
}
if (completed) drawCompletionBadge(context, point.x + 10, point.y - 10)
context.restore()
}
}
function drawOres(context: CanvasRenderingContext2D) {
if (scale.value > SEED_MAP_ORE_MAX_SCALE) return
for (const hit of oreHits.value) {
const point = worldToScreen(hit.x, hit.z)
if (
point.x < -20 ||
point.x > viewport.width + 20 ||
point.y < -20 ||
point.y > viewport.height + 20
)
continue
const key = seedMapOreKey(hit)
const selected = selectedOreKey.value === key
const completed = workspace.completedOres.includes(key)
const definition = seedMapOreDefinition(hit.ore)
const variant = hit.y < 0 && definition.deepslateTexture ? 'deepslate' : 'stone'
const image = oreImages.get(`${hit.ore}:${variant}`)
const size = Math.max(10, Math.min(18, 1.2 / scale.value)) + (selected ? 4 : 0)
context.save()
context.globalAlpha = completed ? 0.42 : hit.verified ? 1 : 0.78
context.imageSmoothingEnabled = false
if (image) {
context.drawImage(image, point.x - size / 2, point.y - size / 2, size, size)
} else {
context.fillStyle = '#B6B7BA'
context.fillRect(point.x - size / 2, point.y - size / 2, size, size)
}
context.strokeStyle = selected ? '#FFFFFF' : 'rgba(15, 18, 20, 0.82)'
context.lineWidth = selected ? 2.5 : 1.5
context.strokeRect(point.x - size / 2, point.y - size / 2, size, size)
if (completed) drawCompletionBadge(context, point.x + size / 2, point.y - size / 2)
context.restore()
}
}
function drawSpawn(context: CanvasRenderingContext2D) {
if (spawn.value && workspace.dimension === 'overworld' && workspace.showSpawn) {
const point = worldToScreen(spawn.value.x, spawn.value.z)
const selected =
selection.value?.spawn === true &&
selection.value.x === spawn.value.x &&
selection.value.z === spawn.value.z
const size = selected ? 36 : 32
context.save()
if (spawnImage) {
context.shadowColor = 'rgba(0, 0, 0, 0.48)'
context.shadowBlur = 5
context.shadowOffsetY = 2
context.drawImage(spawnImage, point.x - size / 2, point.y - size / 2, size, size)
} else {
context.fillStyle = '#2E3138'
context.strokeStyle = '#FFFFFF'
context.lineWidth = 2
context.beginPath()
context.arc(point.x, point.y, 10, 0, Math.PI * 2)
context.fill()
context.stroke()
}
if (selected) {
context.shadowColor = 'transparent'
context.strokeStyle = '#FFFFFF'
context.lineWidth = 2
context.beginPath()
context.arc(point.x, point.y, size / 2 + 2, 0, Math.PI * 2)
context.stroke()
}
context.restore()
}
}
function drawCompletionBadge(context: CanvasRenderingContext2D, x: number, y: number) {
context.globalAlpha = 1
context.shadowColor = 'transparent'
context.fillStyle = '#1BD96A'
context.strokeStyle = 'rgba(15, 18, 20, 0.9)'
context.lineWidth = 2
context.beginPath()
context.arc(x, y, 7, 0, Math.PI * 2)
context.fill()
context.stroke()
context.strokeStyle = '#FFFFFF'
context.lineWidth = 1.75
context.lineCap = 'round'
context.lineJoin = 'round'
context.beginPath()
context.moveTo(x - 3, y)
context.lineTo(x - 0.5, y + 2.5)
context.lineTo(x + 3.5, y - 2.5)
context.stroke()
}
function drawMarkers(context: CanvasRenderingContext2D) {
for (const marker of workspace.markers) {
const point = worldToScreen(marker.x, marker.z)
if (
point.x < -20 ||
point.x > viewport.width + 20 ||
point.y < -20 ||
point.y > viewport.height + 20
)
continue
context.save()
context.fillStyle = marker.color
context.strokeStyle = '#FFFFFF'
context.lineWidth = 2
context.beginPath()
context.arc(point.x, point.y - 3, 7, 0, Math.PI * 2)
context.moveTo(point.x - 4, point.y + 2)
context.lineTo(point.x, point.y + 11)
context.lineTo(point.x + 4, point.y + 2)
context.fill()
context.stroke()
if (showLayerNames.value) {
context.font = '600 11px system-ui'
const width = context.measureText(marker.name).width + 10
context.fillStyle = 'rgba(20, 22, 26, 0.86)'
context.fillRect(point.x + 10, point.y - 12, width, 22)
context.fillStyle = '#FFFFFF'
context.textAlign = 'left'
context.textBaseline = 'middle'
context.fillText(marker.name, point.x + 15, point.y - 1)
}
context.restore()
}
}
function drawRuler(context: CanvasRenderingContext2D) {
if (!rulerPoints.value.length) return
const points = rulerPoints.value.map((point) => worldToScreen(point.x, point.z))
context.save()
context.strokeStyle = '#F2B84B'
context.fillStyle = '#F2B84B'
context.lineWidth = 2
context.setLineDash([6, 4])
context.beginPath()
context.arc(points[0].x, points[0].y, 4, 0, Math.PI * 2)
context.fill()
if (points[1]) {
context.beginPath()
context.moveTo(points[0].x, points[0].y)
context.lineTo(points[1].x, points[1].y)
context.stroke()
context.setLineDash([])
context.beginPath()
context.arc(points[1].x, points[1].y, 4, 0, Math.PI * 2)
context.fill()
}
context.restore()
}
function scheduleRefresh(delay = 80) {
if (!viewport.width || !viewport.height) return
requestEpoch++
clearQueuedTileJobs()
redraw()
if (refreshTimer) window.clearTimeout(refreshTimer)
refreshTimer = window.setTimeout(refreshMap, delay)
}
function refreshMap() {
refreshTimer = undefined
const epoch = requestEpoch
if (!workspace.seed.trim()) {
features.value = []
spawn.value = null
loading.value = false
refreshCurrentOreLayer(0)
redraw()
return
}
if (!selectedProfile.value?.available) {
features.value = []
spawn.value = null
refreshCurrentOreLayer(0)
redraw()
return
}
mapError.value = ''
if (centerOnNextSpawn && workspace.dimension === 'overworld' && workspace.showSpawn) {
void requestSpawn(epoch)
return
}
requestVisibleTiles(epoch)
if (workspace.displayMode === 'structures') void requestFeatures(epoch)
else features.value = []
void requestSpawn(epoch)
refreshCurrentOreLayer()
}
function refreshCurrentOreLayer(delay = 180) {
refreshOreLayer(
{
enabled: workspace.displayMode === 'ores',
seed: workspace.seed,
version: workspace.gameVersion,
dimension: workspace.dimension,
scale: scale.value,
selectedOres: [...activeSelectedOres.value],
yMin: workspace.oreYMin,
yMax: workspace.oreYMax,
bounds: mapBounds(),
center: { ...workspace.center },
},
delay,
)
}
function requestVisibleTiles(epoch: number) {
const bounds = mapBounds()
const requestScale = tileScale.value
const tileSpan = 256 * requestScale
const minTileX = Math.floor(bounds.minX / tileSpan) - TILE_CULL_PADDING
const minTileZ = Math.floor(bounds.minZ / tileSpan) - TILE_CULL_PADDING
const maxTileX = Math.floor(bounds.maxX / tileSpan) + TILE_CULL_PADDING
const maxTileZ = Math.floor(bounds.maxZ / tileSpan) + TILE_CULL_PADDING
const candidates: { tileX: number; tileZ: number }[] = []
for (let tileX = minTileX; tileX <= maxTileX; tileX++) {
for (let tileZ = minTileZ; tileZ <= maxTileZ; tileZ++) candidates.push({ tileX, tileZ })
}
candidates.sort(
(a, b) =>
Math.hypot(
a.tileX * tileSpan + tileSpan / 2 - workspace.center.x,
a.tileZ * tileSpan + tileSpan / 2 - workspace.center.z,
) -
Math.hypot(
b.tileX * tileSpan + tileSpan / 2 - workspace.center.x,
b.tileZ * tileSpan + tileSpan / 2 - workspace.center.z,
),
)
let hasMissingTiles = false
for (const { tileX, tileZ } of candidates) {
const key = tileKey(tileX, tileZ, requestScale)
if (tileImages.has(key) || pendingTiles.get(key) === epoch) continue
hasMissingTiles = true
const request: SeedMapTileRequest = {
epoch,
seed: workspace.seed,
edition: workspace.edition,
version: workspace.gameVersion,
dimension: workspace.dimension,
x: tileX * tileSpan,
z: tileZ * tileSpan,
scale: requestScale,
width: 256,
height: 256,
elevation: workspace.elevation,
terrain: terrainEnabled.value,
contours: contoursEnabled.value,
highlightBiomes:
workspace.highlightBiomeEnabled && activeHighlightedBiomes.value.length > 0
? activeHighlightedBiomes.value
: undefined,
}
pendingTiles.set(key, epoch)
enqueueTile({
epoch,
key,
run: async () => {
try {
if (!isCurrentSeedMapEpoch(epoch, requestEpoch)) {
return
}
const tile = await renderSeedMapTile(request)
if (!isCurrentSeedMapEpoch(tile.epoch, requestEpoch)) {
tile.bitmap.close()
return
}
tileImages.get(key)?.close()
tileImages.set(key, tile.bitmap)
const protectedKeys = visibleTileKeys(mapBounds())
while (tileImages.size > 96) {
const oldest = [...tileImages.keys()].find((candidate) => !protectedKeys.has(candidate))
// Never evict a tile that is currently drawable. The cache may
// temporarily exceed its soft limit when the viewport is large.
if (oldest === undefined) break
tileImages.get(oldest)?.close()
tileImages.delete(oldest)
}
redraw()
} catch (error) {
if (isCurrentSeedMapEpoch(epoch, requestEpoch)) {
mapError.value = error instanceof Error ? error.message : String(error)
}
} finally {
if (pendingTiles.get(key) === epoch) pendingTiles.delete(key)
}
},
})
}
if (!hasMissingTiles) fallbackTileScale = undefined
}
function enqueueTile(job: TileJob) {
tileQueue.push(job)
pumpTileQueue()
}
function pumpTileQueue() {
const concurrency = seedMapTileConcurrency(tileScale.value)
const totalConcurrency = concurrency * 2
while (
(activeTileRequestsByEpoch.get(requestEpoch) ?? 0) < concurrency &&
activeTileRequests < totalConcurrency &&
tileQueue.length
) {
const job = tileQueue.shift()
if (!job) return
activeTileRequests += 1
activeTileRequestsByEpoch.set(job.epoch, (activeTileRequestsByEpoch.get(job.epoch) ?? 0) + 1)
loading.value = true
void job.run().finally(() => {
activeTileRequests -= 1
const epochRequests = (activeTileRequestsByEpoch.get(job.epoch) ?? 1) - 1
if (epochRequests > 0) activeTileRequestsByEpoch.set(job.epoch, epochRequests)
else activeTileRequestsByEpoch.delete(job.epoch)
loading.value =
(activeTileRequestsByEpoch.get(requestEpoch) ?? 0) > 0 ||
tileQueue.some((queuedJob) => queuedJob.epoch === requestEpoch)
if (
!loading.value &&
job.epoch === requestEpoch &&
fallbackTileScale !== undefined &&
!hasMissingCurrentTiles(mapBounds())
) {
fallbackTileScale = undefined
redraw()
}
pumpTileQueue()
})
}
}
function clearQueuedTileJobs() {
const queuedJobs = tileQueue.splice(0, tileQueue.length)
for (const job of queuedJobs) {
if (pendingTiles.get(job.key) === job.epoch) pendingTiles.delete(job.key)
}
}
function clearTileImages() {
for (const bitmap of tileImages.values()) bitmap.close()
tileImages.clear()
}
async function requestFeatures(epoch: number) {
if (activeFeatureMask.value === 0) {
features.value = []
redraw()
return
}
const bounds = mapBounds()
try {
const result = await findSeedMapFeatures({
seed: workspace.seed,
edition: workspace.edition,
version: workspace.gameVersion,
dimension: workspace.dimension,
minX: bounds.minX,
minZ: bounds.minZ,
maxX: bounds.maxX,
maxZ: bounds.maxZ,
featureMask: activeFeatureMask.value,
})
if (epoch !== requestEpoch) return
features.value = result
redraw()
} catch (error) {
if (epoch === requestEpoch)
mapError.value = error instanceof Error ? error.message : String(error)
}
}
async function requestSpawn(epoch: number) {
if (workspace.dimension !== 'overworld' || !workspace.showSpawn) {
spawn.value = null
return
}
const contextKey = `${workspace.seed}|${workspace.edition}|${workspace.gameVersion}`
if (spawn.value && spawnContextKey === contextKey) {
redraw()
return
}
spawn.value = null
try {
const result = await getSeedMapSpawn(workspace.seed, workspace.edition, workspace.gameVersion)
if (epoch !== requestEpoch) return
spawn.value = result
spawnContextKey = contextKey
if (centerOnNextSpawn) {
centerOnNextSpawn = false
workspace.center = { x: result.x, z: result.z }
selection.value = { x: result.x, z: result.z, spawn: true }
scheduleRefresh()
return
}
redraw()
} catch {
if (epoch === requestEpoch) {
spawn.value = null
spawnContextKey = ''
if (centerOnNextSpawn) {
centerOnNextSpawn = false
scheduleRefresh()
}
}
}
}
function onPointerDown(event: PointerEvent) {
if (!canvas.value || event.button !== 0) return
clearWheelGesture()
stopZoomAnimation()
canvas.value.setPointerCapture(event.pointerId)
activePointers.set(event.pointerId, { x: event.offsetX, y: event.offsetY })
if (activePointers.size === 2) {
flushPendingPan()
pinchDistance = currentPinchDistance()
suppressPointerSelection = true
clearLongPress()
return
}
pointer.id = event.pointerId
pointer.startX = event.offsetX
pointer.startY = event.offsetY
pointer.centerX = workspace.center.x
pointer.centerZ = workspace.center.z
pointer.moved = false
suppressPointerSelection = false
if (event.pointerType === 'touch') {
clearLongPress()
longPressTimer = window.setTimeout(() => {
if (pointer.id !== event.pointerId || pointer.moved) return
openMarkerDraftAt(event.offsetX, event.offsetY)
suppressPointerSelection = true
}, 650)
}
}
function onPointerMove(event: PointerEvent) {
if (!activePointers.has(event.pointerId)) return
activePointers.set(event.pointerId, { x: event.offsetX, y: event.offsetY })
if (activePointers.size === 2) {
const distance = currentPinchDistance()
if (pinchDistance > 0 && (distance > pinchDistance * 1.45 || distance < pinchDistance * 0.69)) {
const points = [...activePointers.values()]
zoomAt(
(points[0].x + points[1].x) / 2,
(points[0].y + points[1].y) / 2,
distance > pinchDistance ? -0.35 : 0.35,
)
pinchDistance = distance
}
return
}
if (pointer.id !== event.pointerId) return
const deltaX = event.offsetX - pointer.startX
const deltaY = event.offsetY - pointer.startY
if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) {
pointer.moved = true
clearLongPress()
}
if (!pointer.moved) return
pendingPanCenter = {
x: clampWorldCoordinate(pointer.centerX - deltaX * scale.value),
z: clampWorldCoordinate(pointer.centerZ - deltaY * scale.value),
}
if (panFrame === undefined) panFrame = window.requestAnimationFrame(flushPendingPan)
}
function onPointerUp(event: PointerEvent) {
if (!activePointers.has(event.pointerId)) return
activePointers.delete(event.pointerId)
clearLongPress()
canvas.value?.releasePointerCapture(event.pointerId)
if (pointer.id !== event.pointerId) return
flushPendingPan()
const clicked = !pointer.moved && !suppressPointerSelection
pointer.id = -1
if (clicked) selectAt(event.offsetX, event.offsetY)
else scheduleRefresh(40)
if (activePointers.size < 2) pinchDistance = 0
}
function flushPendingPan() {
if (panFrame !== undefined) window.cancelAnimationFrame(panFrame)
panFrame = undefined
if (!pendingPanCenter) return
workspace.center.x = pendingPanCenter.x
workspace.center.z = pendingPanCenter.z
pendingPanCenter = undefined
redraw()
}
function onWheel(event: WheelEvent) {
event.preventDefault()
const now = performance.now()
const deltaMultiplier =
event.deltaMode === WheelEvent.DOM_DELTA_LINE
? 40
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
? 300
: 1
const normalizedDelta = event.deltaY * deltaMultiplier
if (normalizedDelta === 0) return
if (!wheelMode || now - wheelGestureStartedAt > 400) {
clearWheelGesture()
wheelMode = Math.abs(normalizedDelta) < 4 ? 'trackpad' : 'wheel'
wheelGestureStartedAt = now
if (wheelMode === 'trackpad') stopZoomAnimation()
}
wheelAnchor = { x: event.offsetX, y: event.offsetY }
if (wheelMode === 'trackpad') {
wheelGestureStartedAt = now
applyZoomAt(event.offsetX, event.offsetY, normalizedDelta / 600)
if (trackpadTimer) window.clearTimeout(trackpadTimer)
trackpadTimer = window.setTimeout(() => {
trackpadTimer = undefined
scheduleRefresh(60)
}, 80)
return
}
wheelDelta += normalizedDelta
if (wheelTimer) window.clearTimeout(wheelTimer)
const timeLeft = Math.max(80 - (now - wheelGestureStartedAt), 0)
wheelTimer = window.setTimeout(applyWheelZoom, timeLeft)
}
function applyWheelZoom() {
wheelTimer = undefined
const deltaZoom = Math.min(Math.max(wheelDelta, -300), 300) / 600
const anchor = wheelAnchor
clearWheelGesture()
if (deltaZoom === 0) return
animateZoomTo(
anchor.x,
anchor.y,
Math.min(Math.max(workspace.zoom + deltaZoom, SEED_MAP_MIN_ZOOM), SEED_MAP_SCALES.length - 1),
250,
)
}
function clearWheelGesture() {
if (wheelTimer) window.clearTimeout(wheelTimer)
if (trackpadTimer) window.clearTimeout(trackpadTimer)
wheelTimer = undefined
trackpadTimer = undefined
wheelMode = undefined
wheelGestureStartedAt = 0
wheelDelta = 0
}
function onDoubleClick(event: MouseEvent) {
event.preventDefault()
zoomAt(event.offsetX, event.offsetY, -0.5)
}
function onContextMenu(event: MouseEvent) {
event.preventDefault()
openMarkerDraftAt(event.offsetX, event.offsetY)
}
function onMapKeydown(event: KeyboardEvent) {
const target = event.target
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return
let mapMoved = false
const panDistance = Math.max(
64,
Math.round(Math.min(viewport.width, viewport.height) * scale.value * 0.18),
)
if (event.key === 'ArrowLeft') {
workspace.center.x = clampWorldCoordinate(workspace.center.x - panDistance)
mapMoved = true
} else if (event.key === 'ArrowRight') {
workspace.center.x = clampWorldCoordinate(workspace.center.x + panDistance)
mapMoved = true
} else if (event.key === 'ArrowUp') {
workspace.center.z = clampWorldCoordinate(workspace.center.z - panDistance)
mapMoved = true
} else if (event.key === 'ArrowDown') {
workspace.center.z = clampWorldCoordinate(workspace.center.z + panDistance)
mapMoved = true
} else if (event.key === '+' || event.key === '=') zoomBy(-0.5)
else if (event.key === '-') zoomBy(0.5)
else if (event.key.toLowerCase() === 'f') void toggleFullscreen()
else if (event.key === 'Escape') {
selection.value = null
advancedOpen.value = false
layersExpanded.value = false
if (isFullscreen.value) void document.exitFullscreen()
else return
} else return
event.preventDefault()
if (mapMoved) scheduleRefresh()
}
function currentPinchDistance() {
const points = [...activePointers.values()]
if (points.length !== 2) return 0
return Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y)
}
function clearLongPress() {
if (!longPressTimer) return
window.clearTimeout(longPressTimer)
longPressTimer = undefined
}
function selectAt(x: number, y: number) {
const point = screenToWorld(x, y)
const marker = workspace.markers.find((item) => {
const screen = worldToScreen(item.x, item.z)
return Math.hypot(screen.x - x, screen.y - y) <= 13
})
const feature =
workspace.displayMode === 'structures'
? displayedFeatures.value
.map((item) => ({ item, point: worldToScreen(item.x, item.z) }))
.find((candidate) => Math.hypot(candidate.point.x - x, candidate.point.y - y) <= 13)?.item
: undefined
const ore =
workspace.displayMode === 'ores' && scale.value <= SEED_MAP_ORE_MAX_SCALE
? oreHits.value
.map((item) => ({ item, point: worldToScreen(item.x, item.z) }))
.filter((candidate) => Math.hypot(candidate.point.x - x, candidate.point.y - y) <= 12)
.sort(
(a, b) =>
Math.hypot(a.point.x - x, a.point.y - y) - Math.hypot(b.point.x - x, b.point.y - y),
)[0]?.item
: undefined
const spawnSelected =
workspace.showSpawn &&
spawn.value !== null &&
Math.hypot(
worldToScreen(spawn.value.x, spawn.value.z).x - x,
worldToScreen(spawn.value.x, spawn.value.z).y - y,
) <= 14
selection.value = marker
? { x: marker.x, z: marker.z, markerId: marker.id }
: feature
? { x: feature.x, z: feature.z, feature }
: ore
? { x: ore.x, z: ore.z, ore }
: spawnSelected && spawn.value
? { x: spawn.value.x, z: spawn.value.z, spawn: true }
: point
if (!marker && !feature && !ore && !(spawnSelected && spawn.value)) {
void lookupSelectionBiome(point)
}
markerDraftOpen.value = false
if (rulerEnabled.value) {
rulerPoints.value = rulerPoints.value.length === 1 ? [rulerPoints.value[0], point] : [point]
}
redraw()
}
async function lookupSelectionBiome(point: { x: number; z: number }) {
if (!workspace.seed.trim() || !selectedProfile.value?.available) return
const token = ++biomeLookupToken
try {
const biome = await getSeedMapBiomeAt({
seed: workspace.seed,
edition: workspace.edition,
version: workspace.gameVersion,
dimension: workspace.dimension,
// A tile pixel is sampled at the centre of its source-scale cell.
// Querying the same cell centre keeps the popup biome in
// sync with the color rendered underneath it when zoomed out.
x: Math.floor(point.x / tileScale.value) * tileScale.value + Math.floor(tileScale.value / 2),
y: workspace.elevation,
z: Math.floor(point.z / tileScale.value) * tileScale.value + Math.floor(tileScale.value / 2),
})
if (token !== biomeLookupToken || biome < 0) return
const current = selection.value
if (
!current ||
current.x !== point.x ||
current.z !== point.z ||
current.feature ||
current.ore ||
current.markerId ||
current.spawn
) {
return
}
selection.value = { ...current, biome }
} catch {
// The plain location popup stays when the engine cannot resolve a biome.
}
}
function biomeDisplayName(biome: number) {
const name = SEED_MAP_BIOME_NAMES[biome]
if (!name) return `#${biome}`
return formatMessage({
id: `app.lab.seed-map.biome.${seedMapBiomeSlug(name)}`,
defaultMessage: name,
})
}
function biomeColorOf(biome: number) {
return SEED_MAP_BIOMES.find((item) => item.id === biome)?.color ?? 'transparent'
}
function openMarkerDraftAt(x: number, y: number) {
const point = screenToWorld(x, y)
selection.value = point
markerName.value = ''
markerDraftOpen.value = true
redraw()
}
function zoomAt(x: number, y: number, delta: number) {
clearWheelGesture()
const nextZoom = Math.min(
Math.max(workspace.zoom + delta, SEED_MAP_MIN_ZOOM),
SEED_MAP_SCALES.length - 1,
)
animateZoomTo(x, y, nextZoom, 250)
}
function zoomBy(delta: number) {
zoomAt(viewport.width / 2, viewport.height / 2, delta)
}
function applyZoomAt(x: number, y: number, delta: number) {
const targetZoom = Math.min(
Math.max(workspace.zoom + delta, SEED_MAP_MIN_ZOOM),
SEED_MAP_SCALES.length - 1,
)
if (Math.abs(targetZoom - workspace.zoom) < 0.0001) return
const anchor = {
x: workspace.center.x + (x - viewport.width / 2) * scale.value,
z: workspace.center.z + (y - viewport.height / 2) * scale.value,
}
const previousTileScale = tileScale.value
workspace.zoom = targetZoom
if (tileScale.value !== previousTileScale) fallbackTileScale = previousTileScale
workspace.center.x = clampWorldCoordinate(anchor.x - (x - viewport.width / 2) * scale.value)
workspace.center.z = clampWorldCoordinate(anchor.z - (y - viewport.height / 2) * scale.value)
redraw()
}
function animateZoomTo(x: number, y: number, targetZoom: number, duration: number) {
if (Math.abs(targetZoom - workspace.zoom) < 0.0001) return
if (zoomAnimationFrame !== undefined) window.cancelAnimationFrame(zoomAnimationFrame)
const startTime = performance.now()
const startZoom = workspace.zoom
const anchor = {
x: workspace.center.x + (x - viewport.width / 2) * scale.value,
z: workspace.center.z + (y - viewport.height / 2) * scale.value,
}
const step = (now: number) => {
const progress = Math.min(1, (now - startTime) / duration)
const easedProgress = 1 - (1 - progress) ** 3
const previousTileScale = tileScale.value
workspace.zoom = startZoom + (targetZoom - startZoom) * easedProgress
if (tileScale.value !== previousTileScale) fallbackTileScale = previousTileScale
workspace.center.x = clampWorldCoordinate(anchor.x - (x - viewport.width / 2) * scale.value)
workspace.center.z = clampWorldCoordinate(anchor.z - (y - viewport.height / 2) * scale.value)
redraw()
if (progress < 1) {
zoomAnimationFrame = window.requestAnimationFrame(step)
return
}
// Store the exact endpoint so repeated wheel gestures at the zoom limits
// do not leave a tiny floating-point remainder that restarts the animation.
workspace.zoom = targetZoom
zoomAnimationFrame = undefined
scheduleRefresh(60)
}
zoomAnimationFrame = window.requestAnimationFrame(step)
}
function stopZoomAnimation() {
if (zoomAnimationFrame !== undefined) window.cancelAnimationFrame(zoomAnimationFrame)
zoomAnimationFrame = undefined
}
function toggleFeature(feature: SeedMapFeatureKind) {
workspace.visibleFeatures = workspace.visibleFeatures.includes(feature)
? workspace.visibleFeatures.filter((kind) => kind !== feature)
: [...workspace.visibleFeatures, feature]
}
function toggleOre(ore: SeedMapOreKind) {
workspace.selectedOres = workspace.selectedOres.includes(ore)
? workspace.selectedOres.filter((kind) => kind !== ore)
: [...workspace.selectedOres, ore]
}
function selectAllOres() {
workspace.selectedOres = [
...new Set([...workspace.selectedOres, ...dimensionOres.value.map((ore) => ore.kind)]),
]
}
function clearOres() {
const current = new Set(dimensionOres.value.map((ore) => ore.kind))
workspace.selectedOres = workspace.selectedOres.filter((ore) => !current.has(ore))
}
function resetOres() {
const current = new Set(dimensionOres.value.map((ore) => ore.kind))
const defaultOre = workspace.dimension === 'nether' ? 'netherite' : 'diamond'
workspace.selectedOres = [
...workspace.selectedOres.filter((ore) => !current.has(ore)),
...(current.has(defaultOre) ? [defaultOre] : []),
]
resetOreYRange()
}
function resetOreYRange() {
workspace.oreYMin = null
workspace.oreYMax = null
}
function selectAllFeatures() {
const selected = new Set(workspace.visibleFeatures)
for (const feature of dimensionFeatures.value) selected.add(feature.kind)
workspace.visibleFeatures = [...selected]
}
function clearFeatures() {
const current = new Set(dimensionFeatures.value.map((feature) => feature.kind))
workspace.visibleFeatures = workspace.visibleFeatures.filter((feature) => !current.has(feature))
workspace.showSpawn = false
}
function resetFeatures() {
const defaults = createDefaultSeedMapWorkspace().visibleFeatures
const current = new Set(dimensionFeatures.value.map((feature) => feature.kind))
workspace.visibleFeatures = [
...workspace.visibleFeatures.filter((feature) => !current.has(feature)),
...defaults.filter((feature) => current.has(feature)),
]
workspace.showSpawn = true
}
function randomizeSeed() {
const bytes = new Uint32Array(2)
crypto.getRandomValues(bytes)
const value = (BigInt(bytes[0]) << 32n) | BigInt(bytes[1])
pendingHistorySource = 'random'
workspace.seed = BigInt.asIntN(64, value).toString()
}
function onWorldImported(selection: SeedMapWorldImport) {
workspace.edition = 'java'
if (
selection.version &&
profiles.value.some(
(profile) => profile.edition === 'java' && profile.version === selection.version,
)
) {
workspace.gameVersion = selection.version
}
pendingHistorySource = 'instance'
pendingHistoryLabels = {
instanceName: selection.instance.name,
worldName: selection.world.name,
}
if (workspace.seed === selection.seed) {
recordHistory('instance', pendingHistoryLabels)
pendingHistorySource = null
pendingHistoryLabels = {}
} else {
workspace.seed = selection.seed
}
addNotification({
type: 'success',
title: formatMessage(messages.worldSeedLoaded, { world: selection.world.name }),
})
}
function recordHistory(
source: SeedMapHistorySource,
labels: { instanceName?: string; worldName?: string } = {},
) {
if (!workspace.seed.trim()) return
historyEntries.value = recordSeedMapHistory({
seed: workspace.seed,
edition: workspace.edition,
gameVersion: workspace.gameVersion,
source,
completedFeatures: [...workspace.completedFeatures],
completedOres: [...workspace.completedOres],
...labels,
})
}
function stashActiveProgress() {
historyEntries.value = updateSeedMapHistoryProgress(
activeHistoryId,
[...workspace.completedFeatures],
[...workspace.completedOres],
)
}
function applyHistoryEntry(entry: SeedMapHistoryEntry) {
pendingHistorySource = entry.source
pendingHistoryLabels = { instanceName: entry.instanceName, worldName: entry.worldName }
workspace.edition = entry.edition
if (
profiles.value.some(
(profile) => profile.edition === entry.edition && profile.version === entry.gameVersion,
)
) {
workspace.gameVersion = entry.gameVersion
}
if (workspace.seed === entry.seed) {
if (activeHistoryId !== entry.id) {
stashActiveProgress()
activeHistoryId = entry.id
}
workspace.completedFeatures = [...entry.completedFeatures]
workspace.completedOres = [...entry.completedOres]
recordHistory(entry.source, pendingHistoryLabels)
pendingHistorySource = null
pendingHistoryLabels = {}
} else {
workspace.seed = entry.seed
}
}
function removeHistory(id: string) {
historyEntries.value = removeSeedMapHistoryEntry(id)
}
function clearHistory() {
historyEntries.value = clearSeedMapHistory()
}
function historySourceLabel(entry: SeedMapHistoryEntry) {
if (entry.source === 'instance') {
const parts = [entry.instanceName, entry.worldName].filter(Boolean)
if (parts.length > 0) return parts.join(' · ')
}
if (entry.source === 'random') return formatMessage(messages.historySourceRandom)
if (entry.source === 'share') return formatMessage(messages.historySourceShare)
return formatMessage(messages.historySourceManual)
}
function goToCoordinates() {
const x = Number(coordinateX.value)
const z = Number(coordinateZ.value)
if (!Number.isFinite(x) || !Number.isFinite(z)) return
workspace.center.x = clampWorldCoordinate(x)
workspace.center.z = clampWorldCoordinate(z)
selection.value = { x: Math.round(workspace.center.x), z: Math.round(workspace.center.z) }
scheduleRefresh()
}
function centerSpawn() {
if (!spawn.value) return
workspace.center.x = spawn.value.x
workspace.center.z = spawn.value.z
selection.value = { x: spawn.value.x, z: spawn.value.z, spawn: true }
scheduleRefresh()
}
function addMarker() {
if (!selection.value) return
const marker: SeedMapMarker = {
id: crypto.randomUUID(),
name: markerName.value.trim() || `${selection.value.x}, ${selection.value.z}`,
x: selection.value.x,
z: selection.value.z,
color: markerColor.value,
}
workspace.markers.push(marker)
selection.value = { x: marker.x, z: marker.z, markerId: marker.id }
markerName.value = ''
markerDraftOpen.value = false
addNotification({ type: 'success', title: formatMessage(messages.markerSaved) })
}
function removeMarker(id: string) {
workspace.markers = workspace.markers.filter((marker) => marker.id !== id)
if (selection.value?.markerId === id) selection.value = null
}
function jumpToMarker(marker: SeedMapMarker) {
workspace.center = { x: marker.x, z: marker.z }
selection.value = { x: marker.x, z: marker.z, markerId: marker.id }
advancedOpen.value = false
scheduleRefresh()
}
async function copyTeleport() {
if (!selection.value) return
try {
const y = selection.value.ore?.y ?? '~'
await navigator.clipboard.writeText(`/tp @s ${selection.value.x} ${y} ${selection.value.z}`)
addNotification({ type: 'success', title: formatMessage(messages.teleportCopied) })
} catch (error) {
handleError(error)
}
}
async function copyShareLink() {
try {
const query = new URLSearchParams(createShareQuery(workspace))
await navigator.clipboard.writeText(`axolotl://seed-map?${query.toString()}`)
addNotification({ type: 'success', title: formatMessage(messages.shareCopied) })
} catch (error) {
handleError(error)
}
}
function focusNearestFeature() {
if (!displayedFeatures.value.length) return
const nearest = [...displayedFeatures.value].sort(
(a, b) =>
Math.hypot(a.x - workspace.center.x, a.z - workspace.center.z) -
Math.hypot(b.x - workspace.center.x, b.z - workspace.center.z),
)[0]
selection.value = { x: nearest.x, z: nearest.z, feature: nearest }
workspace.center.x = nearest.x
workspace.center.z = nearest.z
scheduleRefresh()
}
async function toggleFullscreen() {
try {
if (document.fullscreenElement) await document.exitFullscreen()
else await fullscreenContainer.value?.requestFullscreen()
} catch (error) {
handleError(error)
}
}
function onFullscreenChange() {
isFullscreen.value = document.fullscreenElement === fullscreenContainer.value
nextTick(resizeCanvas)
}
function clampWorldCoordinate(value: number) {
return Math.min(Math.max(value, -30_000_000), 30_000_000)
}
</script>
<template>
<main class="seed-map-page mx-auto flex h-full min-h-0 w-full max-w-[110rem] flex-col gap-3 p-4">
<header class="flex min-h-9 items-center justify-between gap-3">
<div class="min-w-0">
<h1 class="m-0 truncate text-xl font-bold text-contrast">
{{ formatMessage(messages.title) }}
</h1>
<p class="m-0 truncate text-xs text-secondary">
{{ editionLabel(workspace.edition) }} {{ workspace.gameVersion }} ·
{{ dimensionLabel(workspace.dimension) }}
</p>
</div>
<div class="seed-map-header-actions flex shrink-0 items-center gap-2">
<span class="map-scale-badge">{{
formatMessage(messages.mapScale, { scale: mapScaleLabel })
}}</span>
<ButtonStyled circular type="outlined">
<button
v-tooltip="formatMessage(messages.copyright)"
:aria-label="formatMessage(messages.copyright)"
@click="copyrightModal?.show($event)"
>
<CircleAlertIcon />
</button>
</ButtonStyled>
</div>
</header>
<section data-onboarding-id="seed-map-toolbar" class="seed-map-toolbar">
<div class="toolbar-primary">
<div class="control-group seed-map-edition">
<span class="control-label">{{ formatMessage(messages.edition) }}</span>
<div class="seed-map-dropdown min-w-0">
<DropdownSelect
v-model="workspace.edition"
:options="editionOptions"
:display-name="editionLabel"
name="Seed map edition"
/>
</div>
</div>
<div class="control-group seed-map-version">
<span class="control-label">{{ formatMessage(messages.gameVersion) }}</span>
<div class="seed-map-dropdown min-w-0">
<DropdownSelect
v-model="workspace.gameVersion"
:options="availableVersions"
name="Seed map version"
/>
</div>
</div>
<div class="control-group seed-control">
<span class="control-label">{{ formatMessage(messages.seed) }}</span>
<div class="seed-input-row">
<StyledInput
v-model="workspace.seed"
:icon="HashIcon"
:placeholder="formatMessage(messages.seedPlaceholder)"
wrapper-class="seed-map-seed w-full"
/>
<ButtonStyled circular type="outlined">
<button
v-tooltip="formatMessage(messages.randomSeed)"
:aria-label="formatMessage(messages.randomSeed)"
@click="randomizeSeed"
>
<RefreshCwIcon />
</button>
</ButtonStyled>
<ButtonStyled circular type="outlined">
<PopoutMenu
:aria-label="formatMessage(messages.history)"
dropdown-class="seed-map-history-popout"
placement="bottom-end"
>
<HistoryIcon />
<template #menu>
<div class="seed-history-menu">
<div class="seed-history-heading">
<strong>{{ formatMessage(messages.history) }}</strong>
<ButtonStyled size="small" type="transparent">
<button :disabled="historyEntries.length === 0" @click="clearHistory">
{{ formatMessage(messages.clearHistory) }}
</button>
</ButtonStyled>
</div>
<p v-if="historyEntries.length === 0" class="seed-history-empty">
{{ formatMessage(messages.historyEmpty) }}
</p>
<div v-else class="seed-history-list">
<div v-for="entry in historyEntries" :key="entry.id" class="seed-history-row">
<button class="seed-history-load" @click="applyHistoryEntry(entry)">
<span class="seed-history-seed">{{ entry.seed }}</span>
<span class="seed-history-meta">
<template v-if="entry.gameVersion">{{ entry.gameVersion }} · </template>
<template v-if="entry.edition === 'java-large-biomes'"
>{{ formatMessage(messages.javaLargeBiomes) }} · </template
>{{ historySourceLabel(entry) }} ·
{{ formatRelativeTime(new Date(entry.lastViewedAt).toISOString())
}}<span
v-if="entry.completedFeatures.length + entry.completedOres.length > 0"
class="seed-history-progress"
>✓
{{
entry.completedFeatures.length + entry.completedOres.length
}}</span
>
</span>
</button>
<ButtonStyled circular size="small" type="transparent">
<button
v-tooltip="formatMessage(messages.removeHistoryEntry)"
class="seed-history-remove"
:aria-label="formatMessage(messages.removeHistoryEntry)"
@click="removeHistory(entry.id)"
>
<TrashIcon />
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
</PopoutMenu>
</ButtonStyled>
<ButtonStyled type="outlined">
<button
v-tooltip="formatMessage(messages.importFromInstance)"
:aria-label="formatMessage(messages.importFromInstance)"
@click="worldImportModal?.show()"
>
<ImportIcon />{{ formatMessage(messages.importFromInstance) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
<div class="toolbar-secondary">
<div class="control-group dimension-group shrink-0">
<span class="control-label">{{ formatMessage(messages.dimension) }}</span>
<div
class="dimension-control flex h-10 items-stretch overflow-hidden rounded-[var(--radius-md)] border border-surface-5 bg-surface-3"
role="group"
:aria-label="formatMessage(messages.dimension)"
>
<ButtonStyled
v-for="dimension in dimensions"
:key="dimension"
size="small"
:type="workspace.dimension === dimension ? 'standard' : 'transparent'"
:color="workspace.dimension === dimension ? 'brand' : 'standard'"
>
<button
class="dimension-option"
:aria-pressed="workspace.dimension === dimension"
@click="workspace.dimension = dimension"
>
{{ dimensionLabel(dimension) }}
</button>
</ButtonStyled>
</div>
</div>
<div class="control-group coordinate-group min-w-80">
<span class="control-label">{{ formatMessage(messages.coordinates) }}</span>
<div class="coordinate-control" :aria-label="formatMessage(messages.coordinates)">
<label class="coordinate-field relative flex min-w-0 items-center"
><span>X</span
><StyledInput v-model="coordinateX" type="number" @keydown.enter="goToCoordinates"
/></label>
<label class="coordinate-field relative flex min-w-0 items-center"
><span>Z</span
><StyledInput v-model="coordinateZ" type="number" @keydown.enter="goToCoordinates"
/></label>
<ButtonStyled color="brand"
><button class="coordinate-go h-10" @click="goToCoordinates">
<CompassIcon />{{ formatMessage(messages.go) }}
</button></ButtonStyled
>
</div>
</div>
<div
v-if="workspace.dimension === 'overworld'"
class="control-group elevation-group shrink-0"
>
<span class="control-label">{{ formatMessage(messages.depth) }}</span>
<div class="seed-map-dropdown min-w-0">
<DropdownSelect
:model-value="workspace.elevation as SeedMapElevation"
:options="elevationOptions"
:display-name="elevationLabel"
name="Seed map elevation"
@update:model-value="setElevation"
/>
</div>
</div>
<div class="share-button">
<ButtonStyled type="outlined">
<button @click="copyShareLink"><ShareIcon />{{ formatMessage(messages.share) }}</button>
</ButtonStyled>
</div>
</div>
</section>
<Admonition
v-if="mapError"
class="seed-map-error p-3"
type="critical"
:header="formatMessage(messages.mapFailed)"
:body="mapError"
/>
<section
ref="fullscreenContainer"
class="seed-map-experience min-h-0 flex-1 bg-surface-1"
data-onboarding-id="seed-map-workspace"
tabindex="0"
@keydown="onMapKeydown"
>
<div class="layer-strip border border-surface-5 bg-surface-2">
<div class="layer-strip-heading">
<span>{{
formatMessage(
workspace.displayMode === 'structures' ? messages.layers : messages.oreLayers,
)
}}</span>
<div class="layer-mode-switch" role="group" :aria-label="formatMessage(messages.layers)">
<ButtonStyled
size="small"
:type="workspace.displayMode === 'structures' ? 'standard' : 'transparent'"
:color="workspace.displayMode === 'structures' ? 'brand' : 'standard'"
>
<button
:aria-pressed="workspace.displayMode === 'structures'"
@click="setDisplayMode('structures')"
>
<LandmarkIcon />{{ formatMessage(messages.structures) }}
</button>
</ButtonStyled>
<ButtonStyled
size="small"
:type="workspace.displayMode === 'ores' ? 'standard' : 'transparent'"
:color="workspace.displayMode === 'ores' ? 'brand' : 'standard'"
>
<button
v-tooltip="oreModeSupported ? undefined : formatMessage(messages.oresRequireModern)"
:aria-pressed="workspace.displayMode === 'ores'"
:disabled="!oreModeSupported"
@click="setDisplayMode('ores')"
>
<PickaxeIcon />{{ formatMessage(messages.ores) }}
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-1">
<ButtonStyled circular type="standard"
><button
v-tooltip="formatMessage(showLayerNames ? messages.hideNames : messages.showNames)"
:aria-label="
formatMessage(showLayerNames ? messages.hideNames : messages.showNames)
"
@click="showLayerNames = !showLayerNames"
>
<EyeOffIcon v-if="showLayerNames" /><EyeIcon v-else /></button
></ButtonStyled>
<ButtonStyled circular type="standard"
><button
v-tooltip="
formatMessage(layersExpanded ? messages.collapseLayers : messages.expandLayers)
"
:aria-label="
formatMessage(layersExpanded ? messages.collapseLayers : messages.expandLayers)
"
@click="layersExpanded = !layersExpanded"
>
<ChevronDownIcon :class="{ 'rotate-180': layersExpanded }" /></button
></ButtonStyled>
</div>
</div>
<div class="layer-options">
<template v-if="workspace.displayMode === 'structures'">
<ButtonStyled
v-for="feature in dimensionFeatures"
:key="feature.kind"
type="standard"
:color="workspace.visibleFeatures.includes(feature.kind) ? 'brand' : 'standard'"
>
<button
v-tooltip="featureTooltip(feature.kind, feature.maxScale)"
class="layer-option w-10 p-1"
:class="{
'opacity-[0.68]': tileScale > feature.maxScale,
named: showLayerNames,
}"
:aria-pressed="workspace.visibleFeatures.includes(feature.kind)"
@click="toggleFeature(feature.kind)"
>
<img
v-if="featureImageSource(feature.kind)"
:src="featureImageSource(feature.kind)"
alt=""
/><component :is="featureIcons[feature.kind]" v-else /><span
v-if="showLayerNames"
>{{ featureLabel(feature.kind) }}</span
>
</button>
</ButtonStyled>
</template>
<template v-else>
<ButtonStyled
v-for="ore in dimensionOres"
:key="ore.kind"
type="standard"
:color="workspace.selectedOres.includes(ore.kind) ? 'brand' : 'standard'"
>
<button
v-tooltip="oreLabel(ore.kind)"
class="layer-option w-10 p-1"
:class="{ named: showLayerNames }"
:aria-pressed="workspace.selectedOres.includes(ore.kind)"
@click="toggleOre(ore.kind)"
>
<img :src="ore.image" alt="" /><span v-if="showLayerNames">{{
oreLabel(ore.kind)
}}</span>
</button>
</ButtonStyled>
</template>
<span
v-if="workspace.displayMode === 'ores' && dimensionOres.length === 0"
class="layer-empty"
>
{{ formatMessage(messages.noOresInEnd) }}
</span>
<ButtonStyled
v-if="workspace.dimension === 'overworld'"
type="standard"
:color="workspace.showSpawn ? 'brand' : 'standard'"
>
<button
v-tooltip="formatMessage(messages.spawnPoint)"
class="layer-option w-10 p-1"
:class="{ named: showLayerNames }"
:aria-pressed="workspace.showSpawn"
@click="workspace.showSpawn = !workspace.showSpawn"
>
<img :src="`${structureAssetRoot}/spawn_point.webp`" alt="" /><span
v-if="showLayerNames"
>{{ formatMessage(messages.spawnPoint) }}</span
>
</button>
</ButtonStyled>
</div>
<div v-if="layersExpanded" class="layer-expanded-panel">
<div class="layer-bulk-actions">
<ButtonStyled size="small" type="outlined"
><button
@click="
workspace.displayMode === 'structures' ? selectAllFeatures() : selectAllOres()
"
>
{{ formatMessage(messages.selectAll) }}
</button></ButtonStyled
>
<ButtonStyled size="small" type="outlined"
><button
@click="workspace.displayMode === 'structures' ? clearFeatures() : clearOres()"
>
{{ formatMessage(messages.clear) }}
</button></ButtonStyled
>
<ButtonStyled size="small" type="outlined"
><button
@click="workspace.displayMode === 'structures' ? resetFeatures() : resetOres()"
>
{{ formatMessage(messages.reset) }}
</button></ButtonStyled
>
</div>
<div class="layer-checklist">
<template v-if="workspace.displayMode === 'structures'">
<Checkbox
v-for="feature in dimensionFeatures"
:key="feature.kind"
:model-value="workspace.visibleFeatures.includes(feature.kind)"
:disabled="tileScale > feature.maxScale"
:description="featureTooltip(feature.kind, feature.maxScale)"
@update:model-value="toggleFeature(feature.kind)"
>
<span class="layer-checklist-label flex min-w-0 items-center gap-2">
<img
v-if="featureImageSource(feature.kind)"
:src="featureImageSource(feature.kind)"
alt=""
/><component :is="featureIcons[feature.kind]" v-else />
<span>{{ featureLabel(feature.kind) }}</span>
</span>
</Checkbox>
</template>
<template v-else>
<Checkbox
v-for="ore in dimensionOres"
:key="ore.kind"
:model-value="workspace.selectedOres.includes(ore.kind)"
:description="oreLabel(ore.kind)"
@update:model-value="toggleOre(ore.kind)"
>
<span class="layer-checklist-label flex min-w-0 items-center gap-2">
<img :src="ore.image" alt="" />
<span>{{ oreLabel(ore.kind) }}</span>
<small>Y {{ ore.yMin }}~{{ ore.yMax }}</small>
</span>
</Checkbox>
</template>
<Checkbox
v-if="workspace.dimension === 'overworld'"
:model-value="workspace.showSpawn"
:description="formatMessage(messages.spawnPoint)"
@update:model-value="workspace.showSpawn = !workspace.showSpawn"
>
<span class="layer-checklist-label flex min-w-0 items-center gap-2">
<img :src="`${structureAssetRoot}/spawn_point.webp`" alt="" />
<span>{{ formatMessage(messages.spawnPoint) }}</span>
</span>
</Checkbox>
</div>
<div
v-if="workspace.displayMode === 'ores' && dimensionOres.length > 0"
class="ore-range-controls"
>
<div class="ore-range-heading">
<strong>{{ formatMessage(messages.oreYRange) }}</strong>
<span>Y {{ oreYMinimum }} - {{ oreYMaximum }}</span>
<ButtonStyled size="small" type="transparent">
<button @click="resetOreYRange">{{ formatMessage(messages.resetYRange) }}</button>
</ButtonStyled>
</div>
<label>
<span>{{ formatMessage(messages.oreMinY) }}</span>
<Slider
v-model="oreYMinimum"
:min="activeOreYRange[0]"
:max="oreYMaximum"
:step="1"
/>
</label>
<label>
<span>{{ formatMessage(messages.oreMaxY) }}</span>
<Slider
v-model="oreYMaximum"
:min="oreYMinimum"
:max="activeOreYRange[1]"
:step="1"
/>
</label>
</div>
</div>
</div>
<div
class="seed-map-canvas-shell relative flex-1 overflow-hidden rounded-b-[var(--radius-md)] min-h-[32rem] border-x border-b border-surface-5 bg-surface-2"
data-onboarding-id="seed-map-canvas"
>
<canvas
ref="mapCanvas"
class="block size-full touch-none"
:class="{ 'cursor-crosshair': rulerEnabled }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
@wheel="onWheel"
@dblclick="onDoubleClick"
@contextmenu="onContextMenu"
/>
<div class="map-coordinate-ruler map-coordinate-ruler-x">
<span v-for="offset in [-2, -1, 0, 1, 2]" :key="offset">{{
Math.round(workspace.center.x + (offset * viewport.width * scale) / 4)
}}</span>
</div>
<div class="map-coordinate-ruler map-coordinate-ruler-z">
<span v-for="offset in [-1, 0, 1]" :key="offset">{{
Math.round(workspace.center.z + (offset * viewport.height * scale) / 3)
}}</span>
</div>
<div class="map-control-stack map-control-stack-left left-3">
<ButtonStyled circular type="standard"
><button
v-tooltip="formatMessage(messages.zoomIn)"
:aria-label="formatMessage(messages.zoomIn)"
:disabled="workspace.zoom <= SEED_MAP_MIN_ZOOM"
@click="zoomBy(-0.5)"
>
<PlusIcon /></button
></ButtonStyled>
<ButtonStyled circular type="standard"
><button
v-tooltip="formatMessage(messages.zoomOut)"
:aria-label="formatMessage(messages.zoomOut)"
:disabled="workspace.zoom >= SEED_MAP_SCALES.length - 1"
@click="zoomBy(0.5)"
>
<MinusIcon /></button
></ButtonStyled>
<ButtonStyled circular type="standard"
><button
v-tooltip="formatMessage(messages.centerSpawn)"
:aria-label="formatMessage(messages.centerSpawn)"
:disabled="!spawn"
@click="centerSpawn"
>
<CompassIcon /></button
></ButtonStyled>
</div>
<div class="map-control-stack map-control-stack-right right-3">
<ButtonStyled circular type="standard"
><button
v-tooltip="
formatMessage(isFullscreen ? messages.exitFullscreen : messages.fullscreen)
"
:aria-label="
formatMessage(isFullscreen ? messages.exitFullscreen : messages.fullscreen)
"
@click="toggleFullscreen"
>
<ContractIcon v-if="isFullscreen" /><ExpandIcon v-else /></button
></ButtonStyled>
<ButtonStyled circular :type="rulerEnabled ? 'standard' : 'outlined'"
><button
v-tooltip="formatMessage(messages.ruler)"
:aria-label="formatMessage(messages.ruler)"
:aria-pressed="rulerEnabled"
@click="rulerEnabled = !rulerEnabled"
>
<ScaleIcon /></button
></ButtonStyled>
<ButtonStyled circular :type="advancedOpen ? 'standard' : 'outlined'"
><button
v-tooltip="formatMessage(messages.mapSettings)"
:aria-label="formatMessage(messages.mapSettings)"
:aria-pressed="advancedOpen"
@click="advancedOpen = !advancedOpen"
>
<SettingsIcon /></button
></ButtonStyled>
</div>
<div v-if="oreZoomLimited" class="map-status map-ore-status">
<PickaxeIcon class="size-3" />{{ formatMessage(messages.zoomToScanOres) }}
</div>
<div
v-else-if="workspace.displayMode === 'ores' && workspace.dimension === 'end'"
class="map-status map-ore-status"
>
<PickaxeIcon class="size-3" />{{ formatMessage(messages.noOresInEnd) }}
</div>
<div v-else-if="mapBusy" class="map-status map-ore-status">
<RefreshCwIcon class="size-3 animate-spin" />{{
oreScanning
? formatMessage(messages.scanningOres, {
processed: oreScannedChunks,
total: oreTotalChunks,
})
: formatMessage(messages.loading)
}}
</div>
<div v-if="rulerDistance !== null" class="map-status map-ruler-status">
<ScaleIcon class="size-3" />{{
formatMessage(messages.distance, { distance: rulerDistance })
}}<ButtonStyled circular size="small" type="transparent"
><button
v-tooltip="formatMessage(messages.clearRuler)"
:aria-label="formatMessage(messages.clearRuler)"
@click="rulerPoints = []"
>
<XIcon /></button
></ButtonStyled>
</div>
<div v-if="selection && selectionVisible" class="map-popup" :style="selectionStyle">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<strong class="block truncate text-sm text-contrast">
<i
v-if="selection.biome !== undefined && !selectedMarker"
class="popup-biome-dot"
:style="{ backgroundColor: biomeColorOf(selection.biome) }"
></i
>{{
selection.feature
? featureResultLabel(selection.feature)
: selection.ore
? oreLabel(selection.ore.ore)
: selection.spawn
? formatMessage(messages.spawnPoint)
: selectedMarker?.name ||
(selection.biome !== undefined
? biomeDisplayName(selection.biome)
: formatMessage(messages.location))
}}
</strong>
<span class="text-xs text-secondary"
>X {{ selection.x
}}<template v-if="selection.ore">, Y {{ selection.ore.y }}</template
>, Z {{ selection.z
}}<template v-if="selection.biome !== undefined && !selectedMarker">
· {{ formatMessage(messages.biomeTag) }}</template
></span
>
</div>
<ButtonStyled circular size="small" type="transparent"
><button :aria-label="formatMessage(messages.closePanel)" @click="selection = null">
<XIcon /></button
></ButtonStyled>
</div>
<div v-if="selection.ore" class="ore-hit-details">
<span :class="{ verified: selection.ore.verified }">
{{
formatMessage(selection.ore.verified ? messages.oreVerified : messages.oreLikely)
}}
</span>
<span>{{
formatMessage(messages.orePrecision, { precision: selection.ore.precision })
}}</span>
<span>{{
formatMessage(messages.oreCoverage, {
min: selection.ore.yMin,
max: selection.ore.yMax,
})
}}</span>
</div>
<div v-if="markerDraftOpen" class="mt-3 grid grid-cols-[minmax(0,1fr)_2.25rem] gap-2">
<StyledInput v-model="markerName" :placeholder="formatMessage(messages.markerName)" />
<input
v-model="markerColor"
type="color"
class="marker-color-input"
:aria-label="formatMessage(messages.markerColor)"
/>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<ButtonStyled v-if="markerDraftOpen" size="small" color="brand"
><button @click="addMarker">
<PinIcon />{{ formatMessage(messages.addMarker) }}
</button></ButtonStyled
>
<ButtonStyled size="small" type="outlined"
><button @click="copyTeleport">
<ClipboardCopyIcon />{{ formatMessage(messages.copyTeleport) }}
</button></ButtonStyled
>
<ButtonStyled v-if="!markerDraftOpen && !selectedMarker" size="small" type="outlined"
><button @click="markerDraftOpen = true">
<PlusIcon />{{ formatMessage(messages.addMarker) }}
</button></ButtonStyled
>
<Checkbox
v-if="selection.feature"
v-model="selectedFeatureCompleted"
class="feature-completed-checkbox"
:label="formatMessage(messages.completed)"
/>
<Checkbox
v-if="selection.ore"
v-model="selectedOreCompleted"
class="feature-completed-checkbox"
:label="formatMessage(messages.mined)"
/>
<ButtonStyled v-if="selectedMarker" size="small" type="outlined"
><button @click="removeMarker(selectedMarker.id)">
<TrashIcon />{{ formatMessage(messages.removeMarker) }}
</button></ButtonStyled
>
</div>
</div>
<div v-if="advancedOpen" class="advanced-panel">
<div class="flex items-center justify-between gap-3">
<strong class="text-sm text-contrast">{{ formatMessage(messages.mapSettings) }}</strong
><ButtonStyled circular size="small" type="transparent"
><button
:aria-label="formatMessage(messages.closePanel)"
@click="advancedOpen = false"
>
<XIcon /></button
></ButtonStyled>
</div>
<label
><span>{{ formatMessage(messages.chunkCoordinates) }}</span
><Toggle v-model="workspace.showChunkCoordinates" small
/></label>
<label
><span>{{ formatMessage(messages.contours) }}</span
><Toggle v-model="contoursEnabled" :disabled="workspace.dimension === 'nether'" small
/></label>
<div class="mt-3 border-t border-surface-5 pt-3">
<div class="mb-2 flex items-center justify-between">
<strong class="text-sm text-contrast">{{ formatMessage(messages.markers) }}</strong
><span class="text-xs text-secondary">{{ workspace.markers.length }}</span>
</div>
<p v-if="!workspace.markers.length" class="m-0 text-xs text-secondary">
{{ formatMessage(messages.noMarkers) }}
</p>
<div v-else class="marker-list flex flex-col gap-1">
<div v-for="marker in workspace.markers" :key="marker.id">
<button class="marker-jump" @click="jumpToMarker(marker)">
<i :style="{ backgroundColor: marker.color }"></i><span>{{ marker.name }}</span
><small>{{ marker.x }}, {{ marker.z }}</small>
</button>
<ButtonStyled circular size="small" type="transparent"
><button
:aria-label="formatMessage(messages.removeMarker)"
@click="removeMarker(marker.id)"
>
<TrashIcon /></button
></ButtonStyled>
</div>
</div>
</div>
</div>
<div class="map-bottom-toolbar">
<SeedMapBiomePicker
class="map-bottom-biomes"
:dimension="workspace.dimension"
:enabled="workspace.highlightBiomeEnabled"
:highlighted-biomes="workspace.highlightedBiomes"
:container="isFullscreen ? fullscreenContainer : undefined"
@update:dimension="workspace.dimension = $event"
@update:enabled="workspace.highlightBiomeEnabled = $event"
@update:highlighted-biomes="workspace.highlightedBiomes = $event"
/>
<div
class="map-bottom-actions max-sm:w-full max-sm:flex-wrap max-sm:justify-start max-sm:ml-0"
>
<ButtonStyled
:type="terrainEnabled ? 'highlight-colored-text' : 'outlined'"
color="brand"
><button
class="map-action"
:disabled="!terrainSupported"
:aria-pressed="terrainEnabled"
@click="terrainEnabled = !terrainEnabled"
>
<LayersIcon />{{ formatMessage(messages.terrain) }}
</button></ButtonStyled
>
<ButtonStyled
:type="workspace.showGrid ? 'highlight-colored-text' : 'outlined'"
color="brand"
><button
class="map-action"
:aria-pressed="workspace.showGrid"
@click="workspace.showGrid = !workspace.showGrid"
>
<GridIcon />{{ formatMessage(messages.showGrid) }}
</button></ButtonStyled
>
<ButtonStyled v-if="workspace.displayMode === 'structures'" type="outlined"
><button
class="map-action"
:disabled="!displayedFeatures.length"
@click="focusNearestFeature"
>
<SearchIcon />{{ formatMessage(messages.searchNearby) }}
</button></ButtonStyled
>
</div>
</div>
</div>
</section>
<SeedMapWorldImportModal ref="worldImportModal" @import="onWorldImported" />
<SeedMapCopyrightModal ref="copyrightModal" />
</main>
</template>
<style scoped>
.map-scale-badge {
flex: 0 0 auto;
min-width: 7.5rem;
border: 1px solid var(--surface-5);
border-radius: var(--radius-sm);
background: var(--surface-2);
padding: 0.35rem 0.55rem;
color: var(--color-text-secondary);
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
font-weight: 700;
text-align: center;
}
.seed-map-toolbar {
position: relative;
z-index: 20;
display: flex;
flex-direction: column;
gap: 0.75rem;
border: 1px solid var(--surface-5);
border-radius: var(--radius-md);
background: var(--surface-2);
padding: 0.75rem;
}
.toolbar-primary {
display: grid;
grid-template-columns: minmax(11rem, 0.8fr) minmax(7rem, 0.45fr) minmax(18rem, 1.75fr);
gap: 0.75rem;
}
/*
* The secondary row is a strict three-column grid whose controls all share
* a 2.5rem height, so the dimension switch, both coordinate fields, Go, and
* Share stay width- and baseline-aligned.
*/
.toolbar-secondary {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: end;
gap: 0.75rem;
border-top: 1px solid var(--surface-5);
padding-top: 0.75rem;
}
.control-group {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.35rem;
}
.control-label {
color: var(--color-text-secondary);
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
}
:deep(.seed-map-dropdown .animated-dropdown) {
width: 100%;
max-width: none;
}
.seed-input-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
align-items: center;
gap: 0.5rem;
}
.seed-history-menu {
display: flex;
width: min(23rem, calc(100vw - 1.5rem));
max-height: min(24rem, calc(100dvh - 4rem));
min-height: 0;
flex-direction: column;
gap: 0;
overflow: hidden;
}
.seed-history-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border-bottom: 1px solid var(--seed-history-border, var(--surface-5));
padding: 0.1rem 0.2rem 0.6rem;
color: var(--color-text-primary);
font-size: 0.8rem;
}
.seed-history-empty {
margin: 0;
padding: 1.25rem 0.5rem;
color: var(--color-secondary);
font-size: 0.78rem;
text-align: center;
}
.seed-history-list {
display: flex;
min-height: 0;
flex-direction: column;
gap: 0.35rem;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.6rem 0.2rem 0.1rem 0;
scrollbar-color: var(--seed-history-border, var(--surface-5)) transparent;
scrollbar-width: thin;
}
.seed-history-row {
display: flex;
align-items: center;
gap: 0.25rem;
border: 1px solid var(--seed-history-border, var(--surface-5));
border-radius: var(--radius-sm);
background: var(--seed-history-row-bg, var(--surface-4));
padding-right: 0.25rem;
transition:
background-color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.seed-history-row:hover {
border-color: color-mix(
in srgb,
var(--color-brand) 45%,
var(--seed-history-border, var(--surface-5))
);
background: var(--seed-history-row-hover-bg, var(--surface-3));
}
.seed-history-row:focus-within {
border-color: var(--color-brand);
box-shadow: 0 0 0 2px var(--color-brand-highlight);
}
.seed-history-load {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 0.15rem;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
padding: 0.5rem 0.6rem;
cursor: pointer;
text-align: left;
}
.seed-history-load:focus-visible {
outline: none;
}
.seed-history-seed {
overflow: hidden;
color: var(--color-contrast);
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.seed-history-meta {
overflow: hidden;
color: var(--color-secondary);
font-size: 0.68rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.seed-history-progress {
display: inline-flex;
align-items: center;
border: 1px solid var(--color-brand-highlight);
border-radius: var(--radius-sm);
background: var(--color-brand-highlight);
margin-left: 0.3rem;
padding: 0 0.3rem;
color: var(--color-brand);
font-weight: 700;
}
.seed-history-remove {
--_text: var(--color-secondary);
--_icon: var(--color-secondary);
--_hover-bg: var(--color-red-highlight);
--_hover-text: var(--color-red);
--_hover-icon: var(--color-red);
}
:global(.v-popper__popper.seed-map-history-popout) {
--seed-history-panel-bg: var(--surface-2);
--seed-history-row-bg: var(--surface-4);
--seed-history-row-hover-bg: var(--surface-3);
--seed-history-border: var(--surface-5);
--seed-history-shadow: 0 18px 48px color-mix(in srgb, var(--surface-5) 55%, transparent);
--_popper-arrow-bg: var(--seed-history-panel-bg);
--_popper-arrow-border: var(--seed-history-border);
z-index: 10050 !important;
}
:global(html.dark-mode .v-popper__popper.seed-map-history-popout),
:global(html.oled-mode .v-popper__popper.seed-map-history-popout) {
--seed-history-panel-bg: color-mix(in srgb, var(--surface-4) 88%, var(--color-text-primary));
--seed-history-row-bg: color-mix(in srgb, var(--surface-5) 82%, var(--color-text-primary));
--seed-history-row-hover-bg: color-mix(in srgb, var(--surface-5) 70%, var(--color-text-primary));
--seed-history-border: color-mix(in srgb, var(--surface-5) 80%, var(--color-text-primary));
--seed-history-shadow: 0 18px 48px color-mix(in srgb, var(--surface-5) 38%, transparent);
}
:global(.v-popper__popper.seed-map-history-popout .v-popper__inner) {
border-color: var(--seed-history-border) !important;
background-color: var(--seed-history-panel-bg) !important;
box-shadow: var(--seed-history-shadow) !important;
}
.popup-biome-dot {
display: inline-block;
width: 0.6rem;
height: 0.6rem;
margin-right: 0.4rem;
border: 1px solid var(--surface-5);
border-radius: 50%;
}
.dimension-control :deep(.btn-wrapper > button.dimension-option) {
height: 100%;
min-width: 5.5rem;
justify-content: center;
border-radius: 0;
}
.dimension-control :deep(.btn-wrapper:first-child button) {
border-radius: calc(var(--radius-md) - 1px) 0 0 calc(var(--radius-md) - 1px);
}
.dimension-control :deep(.btn-wrapper:last-child button) {
border-radius: 0 calc(var(--radius-md) - 1px) calc(var(--radius-md) - 1px) 0;
}
.coordinate-control {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
align-items: center;
gap: 0.5rem;
}
.coordinate-control :deep(input) {
height: 2.5rem;
}
.coordinate-field > span {
position: absolute;
z-index: 2;
left: 0.65rem;
color: var(--color-text-secondary);
font-size: 0.7rem;
font-weight: 800;
pointer-events: none;
}
.coordinate-field :deep(.relative) {
width: 100%;
}
.coordinate-field :deep(input) {
padding-left: 1.75rem;
}
.share-button :deep(button) {
height: 2.5rem;
}
.seed-map-experience {
display: flex;
flex-direction: column;
border-radius: var(--radius-md);
outline: none;
}
.seed-map-experience:fullscreen {
/* Keep the fullscreen surface inside the viewport, including its padding. */
box-sizing: border-box;
width: 100vw;
height: 100dvh;
max-height: 100dvh;
min-height: 0;
overflow: hidden;
padding: 0.75rem;
}
.seed-map-experience:fullscreen .seed-map-canvas-shell {
/* The normal map has a 32rem minimum for usability; fullscreen must be able
* to shrink so the toolbar and canvas do not push the surface past the viewport. */
min-height: 0;
}
.layer-strip {
position: relative;
z-index: 10;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 0.5rem;
min-height: 4rem;
border: 1px solid var(--surface-5);
padding: 0.45rem 0.6rem;
border-radius: var(--radius-md) var(--radius-md) 0 0;
background: var(--surface-3);
box-shadow: 0 8px 20px rgb(0 0 0 / 0.2);
}
/*
* The expanded checklist floats above the map canvas so its options are never
* covered by map overlays and the map does not shift while it is open.
*/
.layer-expanded-panel {
position: absolute;
z-index: 30;
top: calc(100% + 0.4rem);
right: -1px;
left: -1px;
display: flex;
max-height: min(26rem, 56vh);
flex-direction: column;
gap: 0.5rem;
overflow: hidden;
border: 1px solid var(--surface-5);
border-radius: var(--radius-md);
background: var(--surface-3);
box-shadow: 0 16px 34px rgb(0 0 0 / 0.36);
padding: 0.6rem;
}
.layer-strip-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.25rem;
padding-left: 0.25rem;
color: var(--color-text-secondary);
font-size: 0.75rem;
font-weight: 800;
text-transform: uppercase;
}
.layer-strip-heading > span {
white-space: nowrap;
}
.layer-mode-switch {
display: flex;
align-items: center;
gap: 0.2rem;
border: 1px solid var(--surface-5);
border-radius: var(--radius-sm);
background: var(--surface-2);
padding: 0.2rem;
text-transform: none;
}
.layer-mode-switch :deep(button) {
border-radius: var(--radius-sm);
white-space: nowrap;
}
.layer-bulk-actions {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.layer-options {
display: flex;
min-width: 0;
gap: 0.4rem;
overflow-x: auto;
padding: 0.1rem;
scrollbar-width: none;
}
.layer-options::-webkit-scrollbar {
display: none;
}
.layer-checklist {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
gap: 0.35rem 0.75rem;
overflow-y: auto;
overscroll-behavior: contain;
border: 1px solid var(--surface-5);
border-radius: var(--radius-sm);
background: var(--surface-2);
padding: 0.65rem;
}
.layer-checklist > :deep(button) {
min-width: 0;
border-radius: var(--radius-sm);
padding: 0.35rem;
}
.layer-checklist > :deep(button:hover) {
background: var(--surface-4);
}
.layer-checklist-label img,
.layer-checklist-label svg {
width: 1.35rem;
height: 1.35rem;
flex: 0 0 auto;
object-fit: contain;
}
.layer-checklist-label span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.layer-checklist-label small {
margin-left: auto;
color: var(--color-text-secondary);
font-size: 0.68rem;
font-variant-numeric: tabular-nums;
}
.layer-empty {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.5rem;
color: var(--color-text-secondary);
font-size: 0.75rem;
}
.ore-range-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem 1rem;
border: 1px solid var(--surface-5);
border-radius: var(--radius-sm);
background: var(--surface-2);
padding: 0.65rem;
}
.ore-range-heading {
display: flex;
grid-column: 1 / -1;
align-items: center;
gap: 0.65rem;
color: var(--color-text-primary);
font-size: 0.75rem;
}
.ore-range-heading span {
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
.ore-range-heading > :last-child {
margin-left: auto;
}
.ore-range-controls > label {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.4rem;
color: var(--color-text-secondary);
font-size: 0.7rem;
font-weight: 700;
}
.layer-option svg,
.layer-option img {
width: 1.5rem;
height: 1.5rem;
flex: 0 0 auto;
object-fit: contain;
}
.layer-option.named {
width: auto;
min-width: 8rem;
justify-content: flex-start;
padding: 0 0.65rem;
}
.layer-option.named span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.seed-map-canvas-shell canvas {
image-rendering: pixelated;
}
.map-control-stack {
position: absolute;
z-index: 7;
top: 2.5rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
border: 1px solid var(--surface-5);
border-radius: var(--radius-md);
background: var(--surface-3);
box-shadow: 0 8px 20px rgb(0 0 0 / 0.28);
padding: 0.35rem;
}
.map-control-stack :deep(button) {
border-radius: var(--radius-sm);
}
.map-coordinate-ruler {
pointer-events: none;
position: absolute;
z-index: 2;
display: flex;
color: var(--color-text-primary);
font-size: 0.65rem;
font-weight: 700;
}
.map-coordinate-ruler-x {
inset: 0 3.5rem auto 3.5rem;
justify-content: space-between;
}
.map-coordinate-ruler-x span,
.map-coordinate-ruler-z span {
border: 1px solid var(--surface-5);
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
background: var(--surface-3);
padding: 0.15rem 0.35rem;
box-shadow: 0 3px 8px rgb(0 0 0 / 0.24);
}
.map-coordinate-ruler-z {
inset: 3rem auto 3rem 0;
align-items: flex-start;
flex-direction: column;
justify-content: space-between;
}
.map-coordinate-ruler-z span {
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.map-status {
position: absolute;
z-index: 5;
display: flex;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--surface-5);
border-radius: var(--radius-md);
background: var(--surface-3);
padding: 0.35rem 0.55rem;
color: var(--color-text-secondary);
font-size: 0.72rem;
font-weight: 700;
}
.map-status > button {
display: inline-flex;
width: 1.5rem;
height: 1.5rem;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
padding: 0;
color: var(--color-text-secondary);
}
.map-status > button svg {
width: 0.9rem;
height: 0.9rem;
}
.map-ruler-status {
bottom: 5.25rem;
left: 0.75rem;
}
.map-ore-status {
top: 0.75rem;
left: 50%;
transform: translateX(-50%);
}
.map-popup,
.advanced-panel {
position: absolute;
z-index: 8;
width: min(17rem, calc(100% - 1rem));
border: 1px solid var(--surface-5);
border-radius: var(--radius-md);
background: var(--surface-3);
box-shadow: 0 12px 28px rgb(0 0 0 / 0.28);
padding: 0.75rem;
}
.advanced-panel {
right: 0.75rem;
bottom: 4.3rem;
max-height: min(28rem, calc(100% - 7rem));
overflow-y: auto;
}
.advanced-panel > label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 0.75rem;
color: var(--color-text-primary);
font-size: 0.8rem;
}
.marker-list > div {
display: flex;
align-items: center;
gap: 0.35rem;
}
.marker-jump {
display: grid;
min-width: 0;
flex: 1;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.45rem;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
padding: 0.35rem;
color: var(--color-text-primary);
text-align: left;
}
.marker-jump:hover {
background: var(--surface-4);
}
.marker-jump i {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
}
.marker-jump span {
overflow: hidden;
font-size: 0.75rem;
font-style: normal;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.marker-jump small {
color: var(--color-text-secondary);
font-size: 0.65rem;
}
.feature-completed-checkbox {
min-height: 2rem;
border: 1px solid var(--surface-5);
border-radius: var(--radius-sm);
background: var(--surface-4);
padding: 0.35rem 0.55rem;
}
.ore-hit-details {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.6rem;
}
.ore-hit-details span {
border: 1px solid var(--surface-5);
border-radius: var(--radius-sm);
background: var(--surface-4);
padding: 0.2rem 0.4rem;
color: var(--color-text-secondary);
font-size: 0.68rem;
font-weight: 700;
}
.ore-hit-details span.verified {
border-color: var(--color-brand-highlight);
background: var(--color-brand-highlight);
color: var(--color-brand);
}
/*
* One flat row of same-height controls: the biome cluster grows, the height
* pill stays compact, and the action buttons on the right share one width so
* the bar reads as a single aligned toolbar instead of mismatched boxes.
*/
.map-bottom-toolbar {
position: absolute;
z-index: 6;
right: 0.75rem;
bottom: 0.75rem;
left: 0.75rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
border: 1px solid var(--surface-5);
border-radius: var(--radius-md);
background: var(--surface-3);
padding: 0.5rem 0.6rem;
box-shadow: 0 10px 26px rgb(0 0 0 / 0.3);
}
.map-bottom-biomes {
min-width: min(20rem, 100%);
flex: 1 1 20rem;
}
.map-pill {
display: flex;
height: 2.5rem;
flex: 0 0 auto;
align-items: center;
gap: 0.5rem;
border: 1px solid var(--surface-5);
border-radius: var(--radius-md);
background: var(--surface-4);
padding: 0 0.6rem;
color: var(--color-text-primary);
font-size: 0.75rem;
font-weight: 700;
white-space: nowrap;
}
.map-bottom-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.map-bottom-actions :deep(button.map-action) {
min-width: 7.25rem;
height: 2.5rem;
justify-content: center;
white-space: nowrap;
}
.marker-color-input {
width: 2.25rem;
height: 2.25rem;
border: 1px solid var(--surface-5);
border-radius: var(--radius-sm);
background: var(--surface-4);
padding: 0.2rem;
}
@media (max-width: 1200px) {
.toolbar-primary {
grid-template-columns: minmax(11rem, 1fr) minmax(8rem, 0.7fr);
}
.seed-control {
grid-column: 1 / -1;
}
.toolbar-secondary {
grid-template-columns: auto minmax(0, 1fr);
}
.share-button {
grid-column: 1 / -1;
justify-self: end;
}
.coordinate-group {
min-width: min(100%, 22rem);
}
.map-ruler-status {
bottom: 8.5rem;
}
.advanced-panel {
bottom: 7.5rem;
}
}
@media (max-width: 900px) {
.seed-map-page {
height: auto;
min-height: 100%;
}
.toolbar-primary {
display: flex;
flex-direction: column;
}
.toolbar-secondary {
grid-template-columns: minmax(0, 1fr);
}
.dimension-group,
.coordinate-group {
width: 100%;
min-width: 0;
}
.dimension-control,
.coordinate-control {
max-width: 100%;
flex-wrap: wrap;
}
.coordinate-field {
min-width: 7rem;
}
.share-button {
margin-left: 0;
}
.share-button :deep(button) {
width: 100%;
}
.layer-strip {
grid-template-columns: 1fr;
}
.layer-strip-heading {
flex-wrap: wrap;
justify-content: space-between;
}
.layer-mode-switch {
order: 3;
width: 100%;
}
.layer-mode-switch :deep(.button-base),
.layer-mode-switch :deep(button) {
flex: 1;
}
.seed-map-canvas-shell {
min-height: 32rem;
}
}
@media (max-width: 640px) {
.layer-checklist {
grid-template-columns: minmax(0, 1fr);
}
.map-bottom-toolbar {
max-height: calc(100% - 5rem);
overflow-y: auto;
}
.map-ruler-status {
bottom: 12rem;
}
.advanced-panel {
bottom: 10.5rem;
}
.ore-range-controls {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
<style>
/* The seed map is a fixed work surface on desktop. Give the route layers a
* definite height so the page's flex-1 map area cannot grow after a fullscreen
* enter/exit reflow and create a second page scrollbar. */
@media (min-width: 901px) {
.app-viewport:has(.seed-map-page),
.app-viewport:has(.seed-map-page) .page-transition-grid,
.app-viewport:has(.seed-map-page) .page-transition-layer {
height: 100%;
min-height: 0;
overflow: hidden;
}
}
</style>