Index: doc/options.txt
===================================================================
--- doc/options.txt	(revision 4483)
+++ doc/options.txt	(working copy)
@@ -910,6 +910,65 @@
 The tag mkgmap:drawLevel can be used to override the
 natural area of a polygon, so forcing changes to the rendering order.
 
+;--nearby-poi-rules=type[-type][/all|named|unnamed]:distance[:delete-poi|delete-name|merge-at-mid-point][,...]
+:	Defines a set of rules to follow when a POI is near to another of the
+same type. Each rule consists of three parts separated by colons. The
+first two parts must be provided; the last part can be defaulted.
+
+:	The first part of the rule is a Garmin POI type code or range of type codes,
+with an optional suffix; it determines when the rule is triggered. A type code may be
+specified in decimal or hexadecimal (e.g. 0x2c0b). A rule is triggered
+when processing a POI if the type code of the POI matches the rule type
+or falls within the range of type codes,
+providing there is also a match in the POI name and the first part
+suffix. If the suffix is '/all' (the default) then the match is only made
+on the type. If the suffix is '/named' then the rule is only triggered if
+the POI has a name. If the suffix is '/unnamed' then the rule is only
+triggered if the POI has no name. A wildcard of an asterisk character may
+be used to match any type code. The wildcard may also be combined with a
+suffix to allow separate processing of named and unnamed POIs.
+
+:	The second part of the rule is the distance in metres which an already
+processed POI must be within for it to be considered to be nearby and
+hence trigger the action part of the rule.
+
+:	The third part of the rule is the action part and provides three options:
+
+::	delete-poi - the POIS are considered to be duplicates and the
+duplicate is deleted. This is the default.
+::	delete-name - the POIS are not duplicates, but only a single
+name needs to be displayed.
+::	merge-at-mid-point - the POIS are considered to be duplicates
+but the location of the existing point is moved to a point
+midway between the two points.
+
+:	Wildcard rules are only applied if no other rule is applicable.
+
+:	For example:
+:	--nearby-poi-rules=*/named:10,*/unnamed:25,0x2f17-0x2f1f:30
+
+:	This has the following effect:
+:	If no other rule applies, a POI with the same name and type and
+	within 10m of one already processed will be deleted.
+:	If no other rule applies, a POI having no name and of the same type
+	and within 25m of one already processed will be deleted.
+:	A POI of any type between 0x2f17 and 0x2f1f that is within 30m of
+	another POI with the same type will be deleted.
+
+:	If you have a lot of rules, the --nearby-poi-rules-config option is likely to
+be easier to use.
+
+:	Note: a POI that matches another in type, name and exact location is always
+considered a duplicate and deleted.
+
+;--nearby-poi-rules-config=filename
+:Allows you to specify the nearby POI rules as described in the --nearby-poi-rules option
+in a configuration file.
+The format of the rules is the same as in --nearby-poi-rules, except that each rule is
+specified on a separate line, rather than separated by commas. This format makes it easier
+to view and maintain the rules when you have a lot of them. If you just have one or two rules,
+it is simpler to use the --nearby-poi-rules option.
+
 === Deprecated and Obsolete Options ===
 
 ;--drive-on-left
Index: resources/help/en/options
===================================================================
--- resources/help/en/options	(revision 4483)
+++ resources/help/en/options	(working copy)
@@ -886,6 +886,66 @@
     The tag mkgmap:drawLevel can be used to override the natural area of a
     polygon, so forcing changes to the rendering order.
 
+--nearby-poi-rules=type[-type][/all|named|unnamed]:distance[:delete-poi|delete-name|merge-at-mid-point][,...]
+    Defines a set of rules to follow when a POI is near to another of the same
+    type. Each rule consists of three parts separated by colons. The first two
+    parts must be provided; the last part can be defaulted.
+
+    The first part of the rule is a Garmin POI type code or range of type
+    codes, with an optional suffix; it determines when the rule is triggered. A
+    type code may be specified in decimal or hexadecimal (e.g. 0x2c0b). A rule
+    is triggered when processing a POI if the type code of the POI matches the
+    rule type or falls within the range of type codes, providing there is also
+    a match in the POI name and the first part suffix. If the suffix is '/all'
+    (the default) then the match is only made on the type. If the suffix is
+    '/named' then the rule is only triggered if the POI has a name. If the
+    suffix is '/unnamed' then the rule is only triggered if the POI has no
+    name. A wildcard of an asterisk character may be used to match any type
+    code. The wildcard may also be combined with a suffix to allow separate
+    processing of named and unnamed POIs.
+
+    The second part of the rule is the distance in metres which an already
+    processed POI must be within for it to be considered to be nearby and hence
+    trigger the action part of the rule.
+
+    The third part of the rule is the action part and provides three options:
+
+        delete-poi - the POIS are considered to be duplicates and the duplicate
+        is deleted. This is the default.
+        delete-name - the POIS are not duplicates, but only a single name needs
+        to be displayed.
+        merge-at-mid-point - the POIS are considered to be duplicates but the
+        location of the existing point is moved to a point midway between the
+        two points.
+
+    Wildcard rules are only applied if no other rule is applicable.
+
+    For example:
+    --nearby-poi-rules=*/named:10,*/unnamed:25,0x2f17-0x2f1f:30
+
+    This has the following effect:
+    If no other rule applies, a POI with the same name and type and within 10m
+    of one already processed will be deleted.
+    If no other rule applies, a POI having no name and of the same type and
+    within 25m of one already processed will be deleted.
+    A POI of any type between 0x2f17 and 0x2f1f that is within 30m of another
+    POI with the same type will be deleted.
+
+    If you have a lot of rules, the --nearby-poi-rules-config option is likely
+    to be easier to use.
+
+    Note: a POI that matches another in type, name and exact location is always
+    considered a duplicate and deleted.
+
+--nearby-poi-rules-config=filename
+    Allows you to specify the nearby POI rules as described in the
+    --nearby-poi-rules option in a configuration file. The format of the rules
+    is the same as in --nearby-poi-rules, except that each rule is specified on
+    a separate line, rather than separated by commas. This format makes it
+    easier to view and maintain the rules when you have a lot of them. If you
+    just have one or two rules, it is simpler to use the --nearby-poi-rules
+    option.
+
 === Deprecated and Obsolete Options ===
 
 --drive-on-left
Index: src/uk/me/parabola/mkgmap/osmstyle/NearbyPoiHandler.java
===================================================================
--- src/uk/me/parabola/mkgmap/osmstyle/NearbyPoiHandler.java	(nonexistent)
+++ src/uk/me/parabola/mkgmap/osmstyle/NearbyPoiHandler.java	(working copy)
@@ -0,0 +1,265 @@
+/*
+ * Copyright.
+ *
+ * Created by Mike Baggaley 2020
+ * 
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 or
+ * version 2 as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * General Public License for more details.
+ */
+
+package uk.me.parabola.mkgmap.osmstyle;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import uk.me.parabola.imgfmt.app.Coord;
+import uk.me.parabola.log.Logger;
+import uk.me.parabola.mkgmap.general.MapPoint;
+import uk.me.parabola.mkgmap.reader.osm.FakeIdGenerator;
+import uk.me.parabola.mkgmap.reader.osm.GType;
+import uk.me.parabola.mkgmap.reader.osm.Node;
+import uk.me.parabola.util.EnhancedProperties;
+
+public class NearbyPoiHandler {
+	private static final Logger log = Logger.getLogger(NearbyPoiHandler.class);
+	private final HashMap<Integer, Map<String, MapPoint>> pointMap = new HashMap<>();
+
+	private enum NearbyPoiActOn {
+		ActOnAll, ActOnNamed, ActOnUnnamed
+	}
+
+	private enum NearbyPoiAction {
+		DeletePOI, DeleteName, MergeAtMidPoint
+	}
+
+	private class NearbyPoiRule {
+		int minPoiType = 0;
+		int maxPoiType = 0;
+		int maxDistance = 0;
+		NearbyPoiActOn actOn = NearbyPoiActOn.ActOnAll;
+		NearbyPoiAction action = NearbyPoiAction.DeletePOI;
+	}
+
+	private ArrayList<NearbyPoiRule> nearbyPoiRules = new ArrayList<>();
+	private NearbyPoiRule defaultNamedNearbyPoiRule = new NearbyPoiRule();
+	private NearbyPoiRule defaultUnnamedNearbyPoiRule = new NearbyPoiRule();
+
+	public NearbyPoiHandler(EnhancedProperties props) {
+		defaultNamedNearbyPoiRule.actOn = NearbyPoiActOn.ActOnNamed;
+		defaultUnnamedNearbyPoiRule.actOn = NearbyPoiActOn.ActOnUnnamed;
+		String[] rules = props.getProperty("nearby-poi-rules", "").split(",");
+		String rulesFileName = props.getProperty("nearby-poi-rules-config", "");
+		if (!rulesFileName.isEmpty()) {
+			File file = new File(rulesFileName);
+			try {
+				List<String> fileRules = Files.readAllLines(file.toPath());
+				for (int i = 0; i < fileRules.size(); i++) {
+					String rule = fileRules.get(i);
+					int hashPos = rule.indexOf('#');
+					if (hashPos >=0)
+						fileRules.set(i, rule.substring(0, hashPos));
+				}
+				fileRules.addAll(Arrays.asList(rules));
+				rules = fileRules.toArray(rules);
+			} catch (IOException ex) {
+				log.error("Error reading nearby POI rules file", rulesFileName);
+			}
+		}
+		for (int i = 0; i < rules.length; i++) {
+			String rule = rules[i].replaceAll("\\s+", "");
+			if (!rule.isEmpty()) {
+				parseRule(rule, i);
+			}
+		}		
+	}
+	
+	private void parseRule(String rule, int i) {
+		NearbyPoiRule nearbyPoiRule = new NearbyPoiRule();
+		boolean valid = true;
+		String [] ruleParts = rule.split(":", 4);
+		String part1 = ruleParts[0];
+		int slashPos = part1.indexOf('/');
+		if (slashPos > 0) {
+			String part1Suffix = part1.substring(slashPos + 1);
+			part1 = part1.substring(0, slashPos);
+			switch (part1Suffix.toLowerCase()) {
+			case "named":
+				nearbyPoiRule.actOn = NearbyPoiActOn.ActOnNamed;
+				break;
+	
+			case "unnamed":
+				nearbyPoiRule.actOn = NearbyPoiActOn.ActOnUnnamed;
+				break;
+	
+			case "all":
+				break;
+	
+			default:
+				valid = false;
+				log.error("Invalid Act On value", part1Suffix, "in nearby poi rule", i + 1, rule, "- 'all', 'named' or 'unnamed' expected.");
+				break;
+			}
+		}
+		if (!"*".equals(part1)) {
+			String[] poiRange = part1.split("-", 2);
+			try {
+				nearbyPoiRule.minPoiType = Integer.decode(poiRange[0]);
+				if (poiRange.length == 1)
+					nearbyPoiRule.maxPoiType = nearbyPoiRule.minPoiType;
+				else {
+					nearbyPoiRule.maxPoiType = Integer.decode(poiRange[1]);
+					if (nearbyPoiRule.maxPoiType < nearbyPoiRule.minPoiType) {
+						valid = false;
+						log.error("Invalid POI range", part1, "in nearby poi rule", i + 1, rule);
+					}
+				}
+			} catch (Exception ex) {
+				valid = false;
+				log.error("Invalid POI type", part1, "in nearby poi rule", i + 1, rule);
+			}
+		}
+		if (ruleParts.length > 1) {
+			try {
+				nearbyPoiRule.maxDistance = Integer.parseInt(ruleParts[1]);
+			} catch (Exception ex) {
+				valid = false;
+				log.error("Invalid maximum distance", ruleParts[1], "in nearby poi rule", i + 1, rule);
+			}
+			if (ruleParts.length > 2) {
+				switch (ruleParts[2].toLowerCase()) {
+				case "delete-poi":
+					break;
+	
+				case "delete-name":
+					nearbyPoiRule.action = NearbyPoiAction.DeleteName;
+					break;
+	
+				case "merge-at-mid-point":
+					nearbyPoiRule.action = NearbyPoiAction.MergeAtMidPoint;
+					break;
+	
+				default:
+					valid = false;
+					log.error("Invalid Action value", ruleParts[2], "in nearby poi rule", i + 1, rule,
+							"- 'delete-poi', 'delete-name' or'merge-at-mid-point' expected.");
+					break;
+				}
+			}
+			if (ruleParts.length > 3)
+				log.warn("Unexpected text", ruleParts[3], "in nearby poi rule", i + 1, rule);
+			if (valid) {
+				if ("*".equals(part1)) {
+					if (nearbyPoiRule.actOn != NearbyPoiActOn.ActOnUnnamed)
+						defaultNamedNearbyPoiRule = nearbyPoiRule;
+					if (nearbyPoiRule.actOn != NearbyPoiActOn.ActOnNamed)
+						defaultUnnamedNearbyPoiRule = nearbyPoiRule;
+				} else {
+					int index;
+					int count = nearbyPoiRules.size();
+					for (index = 0; index < count; index++) {
+						NearbyPoiRule ruleAtIndex = nearbyPoiRules.get(index);
+						if ((nearbyPoiRule.minPoiType < ruleAtIndex.minPoiType) ||
+							((nearbyPoiRule.minPoiType == ruleAtIndex.minPoiType) &&
+							 ((nearbyPoiRule.maxDistance < ruleAtIndex.maxDistance)))){
+							nearbyPoiRules.add(index, nearbyPoiRule);
+							break;
+						}
+					}
+					if (index == count)
+						nearbyPoiRules.add(nearbyPoiRule);
+				}
+			}
+		} else {
+			log.error("Maximum distance expected for POI type", part1, "in nearby poi rule", i + 1, rule);
+		}
+	}
+
+	private static boolean applyRuleAction(Node node, MapPoint mp, MapPoint old, NearbyPoiAction action) {
+		int type = mp.getType();
+		Coord location = mp.getLocation();
+		switch (action) {
+		case DeletePOI:
+			if (log.isInfoEnabled()) {
+				log.info("Removed nearby duplicate POI with type", GType.formatType(type), mp.getName(), getLogInfo(node));
+			}
+			return true;
+
+		case DeleteName:
+			if (log.isInfoEnabled()) {
+				log.info("Removed name from nearby duplicate POI with type", GType.formatType(type), mp.getName(), getLogInfo(node));
+			}
+			mp.setName("");
+			return false;
+
+		case MergeAtMidPoint:
+			Coord oldLocation = old.getLocation();
+			Coord newLocation = location.makeBetweenPoint(oldLocation, 0.5);
+			old.setLocation(newLocation);
+			if (log.isInfoEnabled()) {
+				log.info("Removed nearby duplicate POI with type", GType.formatType(type), mp.getName(),
+						getLogInfo(node), "and moved location of mearby POI from", oldLocation, "to", newLocation);
+			}
+			return true;
+		}
+		return false; // should not happen
+	}
+	
+	public boolean isDuplicatePOI(Node node, MapPoint mp) {
+		int type = mp.getType();
+		Map<String, MapPoint> typeMap = pointMap.computeIfAbsent(type, HashMap::new);
+		String name = mp.getName();
+		MapPoint old = typeMap.get(name);
+		if (old == null)
+			typeMap.put(mp.getName(), mp);
+		else {
+			Coord location = mp.getLocation();
+			Coord oldLocation = old.getLocation();
+			if ((location != null) && (oldLocation != null)) {					
+				if (oldLocation.equals(location)) {
+					if (log.isInfoEnabled())
+						log.info("Removed duplicate POI with type", GType.formatType(type), mp.getName(), getLogInfo(node));
+					return true;
+				}
+				
+				boolean ruleApplied = false;
+				for (NearbyPoiRule rule : nearbyPoiRules) {
+					if (rule.minPoiType > type)
+						break; // rules are in ascending order of poiType, so we can stop trying other rules
+
+					if ((rule.minPoiType <= type) && (rule.maxPoiType >= type) &&
+						((rule.actOn == NearbyPoiActOn.ActOnAll) ||
+						 ((rule.actOn == NearbyPoiActOn.ActOnNamed) && (name != null) && !name.isEmpty()) ||
+						 ((rule.actOn == NearbyPoiActOn.ActOnUnnamed) && ((name == null) || name.isEmpty())))) {
+						ruleApplied = true;
+						if (location.distance(oldLocation) <= rule.maxDistance)
+							return applyRuleAction(node, mp, old, rule.action);
+					}
+				}
+
+				if (!ruleApplied) {
+					NearbyPoiRule defaultRule = (name == null) ? defaultUnnamedNearbyPoiRule : defaultNamedNearbyPoiRule;
+					if ((location.distance(oldLocation) < defaultRule.maxDistance))
+						return applyRuleAction(node, mp, old, defaultRule.action);
+				}
+			}
+		}
+		return false;
+	}
+	
+	private static String getLogInfo(Node n) {
+		return "for element " + (FakeIdGenerator.isFakeId(n.getId()) ? "generated from " + n.getOriginalId()
+				: n.toBrowseURL() + " at " + n.getLocation().toOSMURL());
+	}
+}
Index: src/uk/me/parabola/mkgmap/osmstyle/StyledConverter.java
===================================================================
--- src/uk/me/parabola/mkgmap/osmstyle/StyledConverter.java	(revision 4483)
+++ src/uk/me/parabola/mkgmap/osmstyle/StyledConverter.java	(working copy)
@@ -160,7 +160,8 @@
 	private final boolean keepBlanks;
 	
 	private LineAdder lineAdder;
-
+	private NearbyPoiHandler nearbyPoiHandler;
+	
 	public StyledConverter(Style style, MapCollector collector, EnhancedProperties props) {
 		this.collector = collector;
 
@@ -215,6 +216,7 @@
 		if (overlayAdder != null)
 			lineAdder = overlayAdder;
 		linkPOIsToWays = props.getProperty("link-pois-to-ways", false);
+		nearbyPoiHandler = new NearbyPoiHandler(props);
 		
 		// undocumented option - usually used for debugging only
 		mergeRoads = !props.getProperty("no-mergeroads", false);
@@ -574,18 +576,8 @@
 			elementSetup(mp, gt, node);
 			mp.setLocation(node.getLocation());
 
-			boolean dupPOI = checkDuplicatePOI(mp);
-			if (dupPOI) {
-				if (log.isInfoEnabled()) {
-					if (FakeIdGenerator.isFakeId(node.getId()))
-						log.info("ignoring duplicate POI with type", GType.formatType(type), mp.getName(),
-								"for generated element with id", node.getId(), "at", mp.getLocation().toDegreeString());
-					else
-						log.info("ignoring duplicate POI with type", GType.formatType(type), mp.getName(),
-								"for element", node.toBrowseURL());
-				}
+			if (nearbyPoiHandler.isDuplicatePOI(node, mp))
 				return;
-			}
 
 			if (node.getLocation() instanceof CoordPOI) {
 				renderedCoordPOI.add(mp);
@@ -594,29 +586,6 @@
 		}
 
 		/**
-		 * Check if we already have added a point with the same type + name and equal
-		 * location.
-		 * 
-		 * @param mp
-		 * @return
-		 */
-		private boolean checkDuplicatePOI(MapPoint mp) {
-			Map<String, MapPoint> typeMap = pointMap.get(mp.getType());
-			if (typeMap == null) {
-				typeMap = new HashMap<>();
-				pointMap.put(mp.getType(), typeMap);
-			}
-			MapPoint old = typeMap.get(mp.getName());
-			if (old == null) {
-				typeMap.put(mp.getName(), mp);
-			} else {
-				if (old.getLocation().equals(mp.getLocation()))
-					return true;
-			}
-			return false;
-		}
-
-		/**
 		 * Retrieves if a rule of the style matched and the node is converted.
 		 * @return {@code true} node is converted; {@code false} node is not converted
 		 */
@@ -1583,9 +1552,9 @@
 						}
 						}
 						if(splitI == p1I) {
-							log.warn("Splitting looped way", way.getDebugName(), "would make a zero length arc, so it will have to be pruned at", wayPoints.get(p2I).toOSMURL());
+							log.info("Splitting looped way", way.getDebugName(), "would make a zero length arc, so it will have to be pruned at", wayPoints.get(p2I).toOSMURL());
 							do {
-								log.warn("  Pruning point[" + p2I + "]");
+								log.info("  Pruning point[" + p2I + "]");
 								wayPoints.remove(p2I);
 								// next point to inspect has same index
 								--p2I;
@@ -1970,7 +1939,7 @@
 	 * As a result, all road junctions have a count > 1. 
 	 */
 	private void setHighwayCounts(){
-		log.info("Maintaining highway counters");
+		log.debug("Maintaining highway counters");
 		long lastId = 0;
 		List<Way> dupIdHighways = new ArrayList<>();
 		for (ConvertedWay cw :roads){
@@ -2014,7 +1983,7 @@
 	 * As a result, all road junctions have a count > 1. 
 	 */
 	private void resetHighwayCounts(){
-		log.info("Resetting highway counters");
+		log.debug("Resetting highway counters");
 		long lastId = 0;
 		for (ConvertedWay cw :roads){
 			if (!cw.isValid())
@@ -2264,7 +2233,7 @@
 	private void filterCoordPOI() {
 		if (!linkPOIsToWays)
 			return;
-		log.info("translating CoordPOI");
+		log.debug("translating CoordPOI");
 		for (ConvertedWay cw: roads) {
 			if (!cw.isValid())
 				continue;
