class Globe
Constants
- BOUNDARY_CHARS
- CENTER_CHAR
- CITIES
- CITY_LIMITS
- CITY_TIERS_BY_ZOOM
- CLOSE_LAT_SPAN
- CLOSE_LON_SPAN
- COUNTRIES
- COUNTRY_SHAPES
- City
- Country
- FUZZY_MATCH_MAX_DISTANCE
- IRC_RESET
- LAND_CHAR
- LAT_SPAN
- LON_SPAN
- MAP_CELL_STYLES
- MAP_HEIGHT
- MAP_WIDTH
- REGIONS
- REGION_BORDER_CHAR
- REGION_PADDING
- Region
- STAGE_LAT_SPAN
- STAGE_LON_SPAN
- STYLE_BOUNDARY
- STYLE_CENTER
- STYLE_CITY
- STYLE_LABEL
- STYLE_LAND
- STYLE_NICK
- STYLE_REGION_BORDER
- STYLE_TITLE
- STYLE_WATER
- WATER_CHAR
- ZOOM_LEVELS
Public Class Methods
Source
# File plugins/globe.rb, line 942 def self.ascii_map(center_lat, center_lon, focus_country: nil, bounds: nil, zoom_level: :regional) bounds ||= viewport_bounds(center_lat, center_lon) grid = Array.new(MAP_HEIGHT) { Array.new(MAP_WIDTH, WATER_CHAR) } country_grid = build_country_grid(bounds: bounds) fill_land(grid, bounds: bounds, country_grid: country_grid) draw_boundaries(grid, country_grid: country_grid) if zoom_level == :regional region_grid = build_region_grid(bounds: bounds) draw_region_boundaries(grid, region_grid: region_grid) end mark_center(grid, center_lat, center_lon, bounds: bounds) place_cities(grid, bounds: bounds, zoom_level: zoom_level) place_labels(grid, bounds: bounds, focus_country: focus_country) grid_to_lines(grid) end
Source
# File plugins/globe.rb, line 713 def self.boundary_char_for(row, col, country_grid, width:, height:) code = country_grid[row][col] return nil unless code north = neighbor_code(country_grid, row - 1, col, width, height) south = neighbor_code(country_grid, row + 1, col, width, height) west = neighbor_code(country_grid, row, col - 1, width, height) east = neighbor_code(country_grid, row, col + 1, width, height) north_diff = north != code south_diff = south != code east_diff = east != code west_diff = west != code return nil unless north_diff || south_diff || east_diff || west_diff corner_count = [north_diff, south_diff, east_diff, west_diff].count(true) return "+" if corner_count >= 2 return "-" if north_diff || south_diff return "|" if east_diff || west_diff "#" end
Source
# File plugins/globe.rb, line 698 def self.build_country_grid(bounds:, width: MAP_WIDTH, height: MAP_HEIGHT) Array.new(height) do |row| Array.new(width) do |col| lat, lon = cell_to_lat_lon(row, col, bounds: bounds, width: width, height: height) country_at?(lat, lon) end end end
Source
# File plugins/globe.rb, line 769 def self.build_region_grid(bounds:, width: MAP_WIDTH, height: MAP_HEIGHT) Array.new(height) do |row| Array.new(width) do |col| lat, lon = cell_to_lat_lon(row, col, bounds: bounds, width: width, height: height) region_at?(lat, lon) end end end
Source
# File plugins/globe.rb, line 857 def self.cell_style_for(char) return :water if char == WATER_CHAR return :land if char == LAND_CHAR return :center if char == CENTER_CHAR return :region_boundary if char == REGION_BORDER_CHAR return :boundary if BOUNDARY_CHARS.include?(char) return :city if char.match?(/[A-Z]/) return :label if char.match?(/[a-z]/) :land end
Source
# File plugins/globe.rb, line 687 def self.cell_to_lat_lon(row, col, bounds:, width: MAP_WIDTH, height: MAP_HEIGHT) lat = bounds[:max_lat] - ((row + 0.5) / height) * bounds[:lat_span] cos_center = bounds[:cos_center] || cos_latitude_scale(bounds[:center_lat]) lon = bounds[:min_lon] + ((col + 0.5) / width) * bounds[:lon_span] * cos_center / cos_latitude_scale(lat) [lat, lon] end
Source
# File plugins/globe.rb, line 810 def self.cities_for_bounds(bounds, zoom_level:) tiers = CITY_TIERS_BY_ZOOM.fetch(zoom_level) candidates = CITIES.select { |city| tiers.include?(city.tier) && city_in_bounds?(city, bounds) } if zoom_level == :close candidates.sort_by do |city| city_sort_key(city, center_lat: bounds[:center_lat], center_lon: bounds[:center_lon]) end else candidates.sort_by { |city| city_sort_key(city) } .first(CITY_LIMITS.fetch(zoom_level)) end end
Source
# File plugins/globe.rb, line 795 def self.city_in_bounds?(city, bounds) city.lat >= bounds[:min_lat] && city.lat <= bounds[:max_lat] && city.lon >= bounds[:min_lon] && city.lon <= bounds[:max_lon] end
Source
# File plugins/globe.rb, line 840 def self.city_placeable?(grid, city, bounds:) row, col = lat_lon_to_cell(city.lat, city.lon, bounds: bounds) return false if grid[row][col] == WATER_CHAR true end
Source
# File plugins/globe.rb, line 800 def self.city_sort_key(city, center_lat: nil, center_lon: nil) tier_rank = { capital: 0, major: 1, town: 2 } if center_lat && center_lon distance = ((city.lat - center_lat)**2) + ((city.lon - center_lon)**2) return [tier_rank[city.tier] || 3, distance, city.name] end [tier_rank[city.tier] || 3, city.name] end
Source
# File plugins/globe.rb, line 869 def self.colorize_map_line(line) return "" if line.empty? runs = [] line.each_char do |char| style = MAP_CELL_STYLES[cell_style_for(char)] if runs.empty? || runs.last[:style] != style runs << { style: style, chars: char } else runs.last[:chars] << char end end runs.map { |run| "#{run[:style]}#{run[:chars]}#{IRC_RESET}" }.join end
Source
# File plugins/globe.rb, line 885 def self.colorize_map_lines(lines) lines.map { |line| colorize_map_line(line) } end
Source
# File plugins/globe.rb, line 499 def self.cos_latitude_scale(lat) Math.cos(lat * Math::PI / 180.0).abs.clamp(0.2, 1.0) end
Source
# File plugins/globe.rb, line 694 def self.country_at?(lat, lon) COUNTRIES.find { |country| country_land?(lat, lon, country) }&.code end
Source
# File plugins/globe.rb, line 517 def self.country_land?(lat, lon, country) return false if lat < country.min_lat || lat > country.max_lat || lon < country.min_lon || lon > country.max_lon shape = country_shape(country) return true unless shape shape.any? { |ring| point_in_polygon?(lat, lon, ring) } end
Source
# File plugins/globe.rb, line 489 def self.country_shape(country) normalize_rings(COUNTRY_SHAPES[country.code]) end
Source
# File plugins/globe.rb, line 736 def self.draw_boundaries(grid, country_grid:, width: MAP_WIDTH, height: MAP_HEIGHT) height.times do |row| width.times do |col| char = boundary_char_for(row, col, country_grid, width: width, height: height) grid[row][col] = char if char end end end
Source
# File plugins/globe.rb, line 778 def self.draw_region_boundaries(grid, region_grid:, width: MAP_WIDTH, height: MAP_HEIGHT) height.times do |row| width.times do |col| code = region_grid[row][col] next unless code north = neighbor_code(region_grid, row - 1, col, width, height) south = neighbor_code(region_grid, row + 1, col, width, height) west = neighbor_code(region_grid, row, col - 1, width, height) east = neighbor_code(region_grid, row, col + 1, width, height) next unless [north, south, west, east].any? { |neighbor| neighbor && neighbor != code } grid[row][col] = REGION_BORDER_CHAR unless BOUNDARY_CHARS.include?(grid[row][col]) end end end
Source
# File plugins/globe.rb, line 641 def self.extract_region_name(result) state = result.state.to_s.strip if result.respond_to?(:state) return state unless state.nil? || state.empty? state_code = result.state_code.to_s.strip if result.respond_to?(:state_code) return state_code unless state_code.nil? || state_code.empty? data = result.data if result.respond_to?(:data) return "" unless data.is_a?(Hash) %w[state state_district region province].each do |key| value = data[key] || data[key.to_sym] return value.to_s.strip unless value.nil? || value.to_s.strip.empty? end "" end
Source
# File plugins/globe.rb, line 745 def self.fill_land(grid, bounds:, country_grid: nil, width: MAP_WIDTH, height: MAP_HEIGHT) country_grid ||= build_country_grid(bounds: bounds, width: width, height: height) height.times do |row| width.times do |col| grid[row][col] = country_grid[row][col] ? LAND_CHAR : WATER_CHAR end end country_grid end
Source
# File plugins/globe.rb, line 575 def self.find_country(name) return nil if name.to_s.strip.empty? match_country(name) || COUNTRIES.find { |country| country.name.casecmp?(name.to_s.strip) } end
Source
# File plugins/globe.rb, line 585 def self.find_region(name) return nil if name.to_s.strip.empty? match_region(name) || REGIONS.find { |region| region.name.casecmp?(name.to_s.strip) } end
Source
# File plugins/globe.rb, line 1038 def self.format_globe(place, search: Geocoder.method(:search)) resolved = resolve_place(place, search: search) return "Could not find #{place}" unless resolved regional_bounds = zoom_bounds(resolved, :regional) focus = resolved[:country] neighbours = visible_neighbors(focus ? focus.name : resolved[:name], bounds: regional_bounds) lines = [ globe_banner(resolved[:name]), *format_zoom_maps(resolved), globe_footer(neighbors: neighbours, coords: [resolved[:lat], resolved[:lon]]) ] lines.join("\n") end
Source
# File plugins/globe.rb, line 1010 def self.format_zoom_maps(resolved) zoom_levels.flat_map do |level| bounds = zoom_bounds(resolved, level) map_lines = render_map(resolved[:lat], resolved[:lon], focus_country: resolved[:country], bounds: bounds, zoom_level: level) [ zoom_divider(resolved, level), map_lines.join("\n") ] end end
Source
# File plugins/globe.rb, line 549 def self.fuzzy_match_country(text, max_distance: FUZZY_MATCH_MAX_DISTANCE) needle = text.to_s.strip.downcase return nil if needle.length < 4 best = nil best_distance = max_distance + 1 COUNTRIES.each do |country| [country.name, *country.aliases].each do |label| candidate = label.to_s.downcase next if (needle.length - candidate.length).abs > max_distance distance = levenshtein_distance(needle, candidate) next if distance > max_distance next if distance >= best_distance best = country best_distance = distance end end best end
Source
# File plugins/globe.rb, line 1055 def self.globe(place, search: Geocoder.method(:search)) text = parse_place(place) return usage_message unless text format_globe(text, search: search) end
Source
# File plugins/globe.rb, line 938 def self.grid_to_lines(grid) grid.map { |row| row.join } end
Source
# File plugins/globe.rb, line 823 def self.label_cells(row, col, label, width: MAP_WIDTH) text = label.to_s[0, 3] start_col = [col - (text.length / 2), 0].max end_col = [start_col + text.length - 1, width - 1].min actual = text[0, end_col - start_col + 1] actual.chars.each_index.map do |index| [row, start_col + index] end end
Source
# File plugins/globe.rb, line 834 def self.label_fits?(grid, row, col, label, width: MAP_WIDTH) label_cells(row, col, label, width: width).all? do |r, c| c < width && !grid[r][c].match?(/[A-Za-z]/) end end
Source
# File plugins/globe.rb, line 755 def self.land_at?(lat, lon) COUNTRIES.any? { |country| country_land?(lat, lon, country) } end
Source
# File plugins/globe.rb, line 680 def self.lat_lon_to_cell(lat, lon, bounds:, width: MAP_WIDTH, height: MAP_HEIGHT) row = ((bounds[:max_lat] - lat) / bounds[:lat_span] * height).floor cos_center = bounds[:cos_center] || cos_latitude_scale(bounds[:center_lat]) col = ((lon - bounds[:min_lon]) / bounds[:lon_span] * width * cos_latitude_scale(lat) / cos_center).floor [row.clamp(0, height - 1), col.clamp(0, width - 1)] end
Source
# File plugins/globe.rb, line 527 def self.levenshtein_distance(a, b) a = a.to_s b = b.to_s return b.length if a.empty? return a.length if b.empty? previous = (0..b.length).to_a (1..a.length).each do |i| current = [i] (1..b.length).each do |j| cost = a[i - 1] == b[j - 1] ? 0 : 1 current << [ current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost ].min end previous = current end previous[b.length] end
Source
# File plugins/globe.rb, line 933 def self.mark_center(grid, lat, lon, bounds:) row, col = lat_lon_to_cell(lat, lon, bounds: bounds) grid[row][col] = CENTER_CHAR end
Source
# File plugins/globe.rb, line 571 def self.match_country(text) COUNTRIES.find { |country| country.matches?(text) } || fuzzy_match_country(text) end
Source
# File plugins/globe.rb, line 581 def self.match_region(text) REGIONS.find { |region| region.matches?(text) } end
Source
# File plugins/globe.rb, line 707 def self.neighbor_code(country_grid, row, col, width, height) return nil if row.negative? || row >= height || col.negative? || col >= width country_grid[row][col] end
Source
# File plugins/globe.rb, line 959 def self.neighbors_for(country_name) country = find_country(country_name) return [] unless country country.neighbors end
Source
# File plugins/globe.rb, line 493 def self.normalize_rings(entry) return nil unless entry entry[0].is_a?(Array) && entry[0][0].is_a?(Array) ? entry : [entry] end
Source
# File plugins/globe.rb, line 896 def self.overlay_label(grid, row, col, label, force: false) return if label.to_s.empty? text = label.to_s[0, 3] start_col = [col - (text.length / 2), 0].max end_col = [start_col + text.length - 1, MAP_WIDTH - 1].min actual = text[0, end_col - start_col + 1] actual.chars.each_with_index do |char, index| target_col = start_col + index next if target_col >= MAP_WIDTH next if !force && grid[row][target_col].match?(/[A-Za-z]/) grid[row][target_col] = char end end
Source
# File plugins/globe.rb, line 484 def self.parse_place(text) value = text.to_s.strip value.empty? ? nil : value end
Source
# File plugins/globe.rb, line 847 def self.place_cities(grid, bounds:, zoom_level:) cities_for_bounds(bounds, zoom_level: zoom_level).each do |city| row, col = lat_lon_to_cell(city.lat, city.lon, bounds: bounds) next unless city_placeable?(grid, city, bounds: bounds) next if zoom_level == :close && !label_fits?(grid, row, col, city.short_label) overlay_label(grid, row, col, city.short_label) end end
Source
# File plugins/globe.rb, line 913 def self.place_labels(grid, bounds:, focus_country: nil) visible = COUNTRIES.select { |country| country.bbox_intersects?(bounds) } visible = visible.sort_by do |country| focus = focus_country && country.name == focus_country.name ? 0 : 1 area = (country.max_lat - country.min_lat) * (country.max_lon - country.min_lon) [focus, area] end visible.each do |country| lat, lon = country.centroid next unless lat >= bounds[:min_lat] && lat <= bounds[:max_lat] && lon >= bounds[:min_lon] && lon <= bounds[:max_lon] row, col = lat_lon_to_cell(lat, lon, bounds: bounds) next if grid[row][col] == WATER_CHAR overlay_label(grid, row, col, country.code, force: true) end end
Source
# File plugins/globe.rb, line 503 def self.point_in_polygon?(lat, lon, ring) inside = false j = ring.length - 1 ring.each_with_index do |(yi, xi), i| yj, xj = ring[j] if ((yi > lat) != (yj > lat)) && (lon < (xj - xi) * (lat - yi) / (yj - yi).to_f + xi) inside = !inside end j = i end inside end
Source
# File plugins/globe.rb, line 763 def self.point_in_region?(lat, lon, region) lat >= region.min_lat && lat <= region.max_lat && lon >= region.min_lon && lon <= region.max_lon && land_at?(lat, lon) end
Source
# File plugins/globe.rb, line 759 def self.region_at?(lat, lon) REGIONS.find { |region| point_in_region?(lat, lon, region) }&.code end
Source
# File plugins/globe.rb, line 591 def self.region_viewport(region, padding: REGION_PADDING) lat_span = (region.max_lat - region.min_lat) * (1 + padding * 2) lon_span = (region.max_lon - region.min_lon) * (1 + padding * 2) center_lat = (region.min_lat + region.max_lat) / 2.0 center_lon = (region.min_lon + region.max_lon) / 2.0 viewport_bounds(center_lat, center_lon, lon_span: lon_span, lat_span: lat_span) end
Source
# File plugins/globe.rb, line 889 def self.render_map(center_lat, center_lon, focus_country: nil, bounds: nil, zoom_level: :regional) colorize_map_lines(ascii_map(center_lat, center_lon, focus_country: focus_country, bounds: bounds, zoom_level: zoom_level)) end
Source
# File plugins/globe.rb, line 974 def self.resolve_place(place, search: Geocoder.method(:search)) text = parse_place(place) return nil unless text result = GeoLookup.lookup_place(text, search: search) if result coords = GeoLookup.coordinates_for(result) if coords lat, lon = coords country_name = result.country.to_s.strip country_name = result.data["country"].to_s.strip if country_name.empty? country_record = find_country(country_name) || match_country(text) region_record = resolve_region(result) return { name: country_name.empty? ? text : country_name, lat: lat, lon: lon, country: country_record, region: region_record&.name, region_record: region_record } end end local = match_country(text) return nil unless local lat, lon = local.centroid { name: local.name, lat: lat, lon: lon, country: local, region: nil, region_record: nil } end
Source
# File plugins/globe.rb, line 659 def self.resolve_region(result) name = extract_region_name(result) return nil if name.empty? find_region(name) end
Source
# File plugins/globe.rb, line 480 def self.usage_message "Usage: !globe <location> โ e.g. !globe iran" end
Source
# File plugins/globe.rb, line 666 def self.viewport_bounds(center_lat, center_lon, lon_span: LON_SPAN, lat_span: LAT_SPAN) { min_lon: center_lon - (lon_span / 2.0), max_lon: center_lon + (lon_span / 2.0), min_lat: center_lat - (lat_span / 2.0), max_lat: center_lat + (lat_span / 2.0), lon_span: lon_span, lat_span: lat_span, center_lat: center_lat, center_lon: center_lon, cos_center: cos_latitude_scale(center_lat) } end
Source
# File plugins/globe.rb, line 966 def self.visible_neighbors(country_name, bounds:) names = neighbors_for(country_name) names.select do |neighbor| neighbor_country = find_country(neighbor) neighbor_country && neighbor_country.bbox_intersects?(bounds) end end
Source
# File plugins/globe.rb, line 617 def self.zoom_bounds(resolved, level) lat = resolved[:lat] lon = resolved[:lon] region = resolved[:region_record] case level when :regional viewport_bounds(lat, lon, lon_span: LON_SPAN, lat_span: LAT_SPAN) when :stage if region region_viewport(region) else viewport_bounds(lat, lon, lon_span: STAGE_LON_SPAN, lat_span: STAGE_LAT_SPAN) end when :close stage = zoom_bounds(resolved, :stage) lon_span = [stage[:lon_span] / 3.0, CLOSE_LON_SPAN].min lat_span = [stage[:lat_span] / 3.0, CLOSE_LAT_SPAN].min viewport_bounds(lat, lon, lon_span: lon_span, lat_span: lat_span) else viewport_bounds(lat, lon) end end
Source
# File plugins/globe.rb, line 1006 def self.zoom_divider(resolved, level) IrcFormat.divider(zoom_label(level, resolved)) end
Source
# File plugins/globe.rb, line 603 def self.zoom_label(level, resolved) case level when :regional "Regional" when :stage region = resolved[:region].to_s.strip region.empty? ? "Stage" : "Stage ยท #{region}" when :close "Local" else level.to_s.capitalize end end
Public Instance Methods
Source
# File plugins/globe.rb, line 1066 def execute(m, place = nil) themed_flood_safe_reply(m, self.class.globe(place)) end
Source
# File plugins/globe.rb, line 1062 def globe(place) self.class.globe(place) end