Merge develop

This commit is contained in:
Pieter Vander Vennet 2022-01-05 18:18:13 +01:00
commit ac1b4a010c
40 changed files with 5706 additions and 4746 deletions

View file

@ -85,7 +85,6 @@ class IntersectionFunc implements ExtraFunction {
const bbox = BBox.get(feat)
for (const layerId of layerIds) {
console.log("Calculating the intersection with layer ", layerId)
const otherLayers = params.getFeaturesWithin(layerId, bbox)
if (otherLayers === undefined) {
continue;

View file

@ -60,6 +60,12 @@ export default class FeaturePipeline {
private readonly localStorageSavers = new Map<string, SaveTileToLocalStorageActor>()
private readonly metataggingRecalculated = new UIEventSource<void>(undefined)
/**
* Keeps track of all raw OSM-nodes.
* Only initialized if 'type_node' is defined as layer
*/
public readonly fullNodeDatabase? : FullNodeDatabaseSource
constructor(
handleFeatureSource: (source: FeatureSourceForLayer & Tiled) => void,
state: MapState) {
@ -129,7 +135,14 @@ export default class FeaturePipeline {
this.freshnesses.set(id, new TileFreshnessCalculator())
if (id === "type_node") {
// Handles by the 'FullNodeDatabaseSource'
this.fullNodeDatabase = new FullNodeDatabaseSource(
filteredLayer,
tile => {
new RegisteringAllFromFeatureSourceActor(tile, state.allElements)
perLayerHierarchy.get(tile.layer.layerDef.id).registerTile(tile)
tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile))
});
continue;
}
@ -248,17 +261,8 @@ export default class FeaturePipeline {
})
})
if (state.layoutToUse.trackAllNodes) {
const fullNodeDb = new FullNodeDatabaseSource(
state.filteredLayers.data.filter(l => l.layerDef.id === "type_node")[0],
tile => {
new RegisteringAllFromFeatureSourceActor(tile, state.allElements)
perLayerHierarchy.get(tile.layer.layerDef.id).registerTile(tile)
tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile))
})
osmFeatureSource.rawDataHandlers.push((osmJson, tileId) => fullNodeDb.handleOsmJson(osmJson, tileId))
if(this.fullNodeDatabase !== undefined){
osmFeatureSource.rawDataHandlers.push((osmJson, tileId) => this.fullNodeDatabase.handleOsmJson(osmJson, tileId))
}

View file

@ -9,6 +9,7 @@ export default class FullNodeDatabaseSource implements TileHierarchy<FeatureSour
public readonly loadedTiles = new Map<number, FeatureSource & Tiled>()
private readonly onTileLoaded: (tile: (Tiled & FeatureSourceForLayer)) => void;
private readonly layer: FilteredLayer
private readonly nodeByIds = new Map<number, OsmNode>();
constructor(
layer: FilteredLayer,
@ -31,6 +32,7 @@ export default class FullNodeDatabaseSource implements TileHierarchy<FeatureSour
}
const osmNode = <OsmNode>osmObj;
nodesById.set(osmNode.id, osmNode)
this.nodeByIds.set(osmNode.id, osmNode)
}
const parentWaysByNodeId = new Map<number, OsmWay[]>()
@ -49,6 +51,7 @@ export default class FullNodeDatabaseSource implements TileHierarchy<FeatureSour
}
parentWaysByNodeId.forEach((allWays, nodeId) => {
nodesById.get(nodeId).tags["parent_ways"] = JSON.stringify(allWays.map(w => w.tags))
nodesById.get(nodeId).tags["parent_way_ids"] = JSON.stringify(allWays.map(w => w.id))
})
const now = new Date()
const asGeojsonFeatures = Array.from(nodesById.values()).map(osmNode => ({
@ -62,6 +65,16 @@ export default class FullNodeDatabaseSource implements TileHierarchy<FeatureSour
}
/**
* Returns the OsmNode with the corresponding id (undefined if not found)
* Note that this OsmNode will have a calculated tag 'parent_ways' and 'parent_way_ids', which are resp. stringified lists of parent way tags and ids
* @param id
* @constructor
*/
public GetNode(id: number) : OsmNode {
return this.nodeByIds.get(id)
}
}

View file

@ -357,7 +357,6 @@ export class GeoOperations {
* Returns null if the features are not intersecting
*/
private static calculateInstersection(feature, otherFeature, featureBBox: BBox, otherFeatureBBox?: BBox): number {
try {
if (feature.geometry.type === "LineString") {
@ -427,19 +426,25 @@ export class GeoOperations {
return this.calculateInstersection(otherFeature, feature, otherFeatureBBox, featureBBox)
}
try{
const intersection = turf.intersect(feature, otherFeature);
if (intersection == null) {
return null;
}
return turf.area(intersection); // in m²
}catch(e){
if(e.message === "Each LinearRing of a Polygon must have 4 or more Positions."){
// WORKAROUND TIME!
// See https://github.com/Turfjs/turf/pull/2238
return null;
}
throw e;
}
}
throw "CalculateIntersection fallthrough: can not calculate an intersection between features"
} catch (exception) {
console.warn("EXCEPTION CAUGHT WHILE INTERSECTING: ", exception,"\nThe considered objects are",feature, otherFeature);
return undefined
}
return undefined;
}
/**

View file

@ -1,4 +1,4 @@
import OsmChangeAction, {OsmCreateAction} from "./OsmChangeAction";
import {OsmCreateAction} from "./OsmChangeAction";
import {Tag} from "../../Tags/Tag";
import {Changes} from "../Changes";
import {ChangeDescription} from "./ChangeDescription";
@ -18,10 +18,34 @@ export interface MergePointConfig {
mode: "reuse_osm_point" | "move_osm_point"
}
/**
* CreateWayWithPointreuse will create a 'CoordinateInfo' for _every_ point in the way to be created.
*
* The CoordinateInfo indicates the action to take, e.g.:
*
* - Create a new point
* - Reuse an existing OSM point (and don't move it)
* - Reuse an existing OSM point (and leave it where it is)
* - Reuse another Coordinate info (and don't do anything else with it)
*
*/
interface CoordinateInfo {
/**
* The new coordinate
*/
lngLat: [number, number],
/**
* If set: indicates that this point is identical to an earlier point in the way and that that point should be used.
* This is especially needed in closed ways, where the last CoordinateInfo will have '0' as identicalTo
*/
identicalTo?: number,
/**
* Information about the closebyNode which might be reused
*/
closebyNodes?: {
/**
* Distance in meters between the target coordinate and this candidate coordinate
*/
d: number,
node: any,
config: MergePointConfig
@ -53,6 +77,8 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
this._tags = tags;
this._state = state;
this._config = config;
// The main logic of this class: the coordinateInfo contains all the changes
this._coordinateInfo = this.CalculateClosebyNodes(coordinates);
}
@ -219,6 +245,9 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
return allChanges
}
/**
* Calculates the main changes.
*/
private CalculateClosebyNodes(coordinates: [number, number][]): CoordinateInfo[] {
const bbox = new BBox(coordinates)
@ -226,6 +255,7 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
const allNodes = [].concat(...state.featurePipeline.GetFeaturesWithin("type_node", bbox.pad(1.2)))
const maxDistance = Math.max(...this._config.map(c => c.withinRangeOfM))
// Init coordianteinfo with undefined but the same length as coordinates
const coordinateInfo: {
lngLat: [number, number],
identicalTo?: number,
@ -236,6 +266,8 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
}[]
}[] = coordinates.map(_ => undefined)
// First loop: gather all information...
for (let i = 0; i < coordinates.length; i++) {
if (coordinateInfo[i] !== undefined) {
@ -243,8 +275,11 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
continue
}
const coor = coordinates[i]
// Check closeby (and probably identical) point further in the coordinate list, mark them as duplicate
// Check closeby (and probably identical) points further in the coordinate list, mark them as duplicate
for (let j = i + 1; j < coordinates.length; j++) {
// We look into the 'future' of the way and mark those 'future' locations as being the same as this location
// The continue just above will make sure they get ignored
// This code is important to 'close' ways
if (GeoOperations.distanceBetween(coor, coordinates[j]) < 0.1) {
coordinateInfo[j] = {
lngLat: coor,
@ -280,6 +315,7 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
}
}
// Sort by distance, closest first
closebyNodes.sort((n0, n1) => {
return n0.d - n1.d
})
@ -292,8 +328,9 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
}
// Second loop: figure out which point moves where without creating conflicts
let conflictFree = true;
do {
conflictFree = true;
for (let i = 0; i < coordinateInfo.length; i++) {

View file

@ -37,7 +37,7 @@ export default class DeleteAction extends OsmChangeAction {
}
protected async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
public async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const osmObject = await OsmObject.DownloadObjectAsync(this._id)

View file

@ -11,28 +11,35 @@ import ChangeTagAction from "./ChangeTagAction";
import {And} from "../../Tags/And";
import {Utils} from "../../../Utils";
import {OsmConnection} from "../OsmConnection";
import {GeoJSONObject} from "@turf/turf";
import FeaturePipeline from "../../FeatureSource/FeaturePipeline";
export default class ReplaceGeometryAction extends OsmChangeAction {
/**
* The target feature - mostly used for the metadata
*/
private readonly feature: any;
private readonly state: {
osmConnection: OsmConnection
osmConnection: OsmConnection,
featurePipeline: FeaturePipeline
};
private readonly wayToReplaceId: string;
private readonly theme: string;
/**
* The target coordinates that should end up in OpenStreetMap
* The target coordinates that should end up in OpenStreetMap.
* This is identical to either this.feature.geometry.coordinates or -in case of a polygon- feature.geometry.coordinates[0]
*/
private readonly targetCoordinates: [number, number][];
/**
* If a target coordinate is close to another target coordinate, 'identicalTo' will point to the first index.
* @private
*/
private readonly identicalTo: number[]
private readonly newTags: Tag[] | undefined;
constructor(
state: {
osmConnection: OsmConnection
osmConnection: OsmConnection,
featurePipeline: FeaturePipeline
},
feature: any,
wayToReplaceId: string,
@ -54,6 +61,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
} else if (geom.type === "Polygon") {
coordinates = geom.coordinates[0]
}
this.targetCoordinates = coordinates
this.identicalTo = coordinates.map(_ => undefined)
@ -68,21 +76,18 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
}
}
}
this.targetCoordinates = coordinates
this.newTags = options.newTags
}
// noinspection JSUnusedGlobalSymbols
public async getPreview(): Promise<FeatureSource> {
const {closestIds, allNodesById} = await this.GetClosestIds();
const {closestIds, allNodesById, detachedNodeIds} = await this.GetClosestIds();
console.debug("Generating preview, identicals are ",)
const preview = closestIds.map((newId, i) => {
const preview: GeoJSONObject[] = closestIds.map((newId, i) => {
if (this.identicalTo[i] !== undefined) {
return undefined
}
if (newId === undefined) {
return {
type: "Feature",
@ -110,6 +115,24 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
}
};
})
for (const detachedNodeId of detachedNodeIds) {
const origPoint = allNodesById.get(detachedNodeId).centerpoint()
const feature = {
type: "Feature",
properties: {
"detach": "yes",
"id": "replace-geometry-detach-" + detachedNodeId
},
geometry: {
type: "Point",
coordinates: [origPoint[1], origPoint[0]]
}
};
preview.push(feature)
}
return new StaticFeatureSource(Utils.NoNull(preview), false)
}
@ -119,7 +142,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
const allChanges: ChangeDescription[] = []
const actualIdsToUse: number[] = []
const {closestIds, osmWay} = await this.GetClosestIds()
const {closestIds, osmWay, detachedNodeIds} = await this.GetClosestIds()
for (let i = 0; i < closestIds.length; i++) {
if (this.identicalTo[i] !== undefined) {
@ -170,7 +193,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
allChanges.push(...await addExtraTags.CreateChangeDescriptions(changes))
}
// AT the very last: actually change the nodes of the way!
// Actually change the nodes of the way!
allChanges.push({
type: "way",
id: osmWay.id,
@ -185,92 +208,170 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
})
// Some nodes might need to be deleted
if (detachedNodeIds.length > 0) {
const nodeDb = this.state.featurePipeline.fullNodeDatabase;
if (nodeDb === undefined) {
throw "PANIC: replaceGeometryAction needs the FullNodeDatabase, which is undefined. This should be initialized by having the 'type_node'-layer enabled in your theme. (NB: the replacebutton has type_node as dependency)"
}
for (const nodeId of detachedNodeIds) {
const osmNode = nodeDb.GetNode(nodeId)
const parentWayIds: number[] = JSON.parse(osmNode.tags["parent_way_ids"])
const index = parentWayIds.indexOf(osmWay.id)
if(index < 0){
console.error("ReplaceGeometryAction is trying to detach node "+nodeId+", but it isn't listed as being part of way "+osmWay.id)
continue;
}
parentWayIds.splice(index, 1)
osmNode.tags["parent_way_ids"] = JSON.stringify(parentWayIds)
if(parentWayIds.length == 0){
// This point has no other ways anymore - lets clean it!
console.log("Removing node "+nodeId, "as it isn't needed anymore by any way")
allChanges.push({
meta: {
theme: this.theme,
changeType: "delete"
},
doDelete: true,
type: "node",
id: nodeId,
})
}
}
}
return allChanges
}
/**
* For 'this.feature`, gets a corresponding closest node that alreay exsists
* @constructor
* @private
* For 'this.feature`, gets a corresponding closest node that alreay exsists.
*
* This method contains the main logic for this module, as it decides which node gets moved where.
*
*/
private async GetClosestIds(): Promise<{ closestIds: number[], allNodesById: Map<number, OsmNode>, osmWay: OsmWay }> {
private async GetClosestIds(): Promise<{
// A list of the same length as targetCoordinates, containing which OSM-point to move. If undefined, a new point will be created
closestIds: number[],
allNodesById: Map<number, OsmNode>,
osmWay: OsmWay,
detachedNodeIds: number[]
}> {
// TODO FIXME: cap move length on points which are embedded into other ways (ev. disconnect them)
// TODO FIXME: if a new point has to be created, snap to already existing ways
// TODO FIXME: detect intersections with other ways if moved
const splitted = this.wayToReplaceId.split("/");
const type = splitted[0];
const idN = Number(splitted[1]);
if (idN < 0 || type !== "way") {
throw "Invalid ID to conflate: " + this.wayToReplaceId
}
const url = `${this.state.osmConnection._oauth_config.url}/api/0.6/${this.wayToReplaceId}/full`;
const rawData = await Utils.downloadJsonCached(url, 1000)
const parsed = OsmObject.ParseObjects(rawData.elements);
const allNodesById = new Map<number, OsmNode>()
const allNodes = parsed.filter(o => o.type === "node")
for (const node of allNodes) {
allNodesById.set(node.id, <OsmNode>node)
}
let parsed: OsmObject[];
{
// Gather the needed OsmObjects
const splitted = this.wayToReplaceId.split("/");
const type = splitted[0];
const idN = Number(splitted[1]);
if (idN < 0 || type !== "way") {
throw "Invalid ID to conflate: " + this.wayToReplaceId
}
const url = `${this.state.osmConnection._oauth_config.url}/api/0.6/${this.wayToReplaceId}/full`;
const rawData = await Utils.downloadJsonCached(url, 1000)
parsed = OsmObject.ParseObjects(rawData.elements);
}
const allNodes = parsed.filter(o => o.type === "node")
/**
* Allright! We know all the nodes of the original way and all the nodes of the target coordinates.
* For each of the target coordinates, we search the closest, already existing point and reuse this point
* For every already existing OSM-point, we calculate the distance to every target point
*/
const closestIds = []
const distances = []
for (let i = 0; i < this.targetCoordinates.length; i++) {
const target = this.targetCoordinates[i];
let closestDistance = undefined
let closestId = undefined;
for (const osmNode of allNodes) {
const cp = osmNode.centerpoint()
const d = GeoOperations.distanceBetween(target, [cp[1], cp[0]])
if (closestId === undefined || closestDistance > d) {
closestId = osmNode.id
closestDistance = d
const distances = new Map<number /* osmId*/, number[] /* target coordinate index --> distance (or undefined if a duplicate)*/>();
for (const node of allNodes) {
const nodeDistances = this.targetCoordinates.map(_ => undefined)
for (let i = 0; i < this.targetCoordinates.length; i++) {
if (this.identicalTo[i] !== undefined) {
continue;
}
const targetCoordinate = this.targetCoordinates[i];
const cp = node.centerpoint()
nodeDistances[i] = GeoOperations.distanceBetween(targetCoordinate, [cp[1], cp[0]])
}
closestIds.push(closestId)
distances.push(closestDistance)
distances.set(node.id, nodeDistances)
}
// Next step: every closestId can only occur once in the list
// We skip the ones which are identical
console.log("Erasing double ids")
for (let i = 0; i < closestIds.length; i++) {
if (this.identicalTo[i] !== undefined) {
closestIds[i] = closestIds[this.identicalTo[i]]
continue
}
const closestId = closestIds[i]
for (let j = i + 1; j < closestIds.length; j++) {
if (this.identicalTo[j] !== undefined) {
continue
/**
* Then, we search the node that has to move the least distance and add this as mapping.
* We do this until no points are left
*/
let candidate: number;
let moveDistance: number;
const closestIds = this.targetCoordinates.map(_ => undefined)
/**
* The list of nodes that are _not_ used anymore, typically if there are less targetCoordinates then source coordinates
*/
const unusedIds = []
do {
candidate = undefined;
moveDistance = Infinity;
distances.forEach((distances, nodeId) => {
const minDist = Math.min(...Utils.NoNull(distances))
if (moveDistance > minDist) {
// We have found a candidate to move
candidate = nodeId
moveDistance = minDist
}
const otherClosestId = closestIds[j]
if (closestId !== otherClosestId) {
continue
})
if (candidate !== undefined) {
// We found a candidate... Search the corresponding target id:
let targetId: number = undefined;
let lowestDistance = Number.MAX_VALUE
let nodeDistances = distances.get(candidate)
for (let i = 0; i < nodeDistances.length; i++) {
const d = nodeDistances[i]
if (d !== undefined && d < lowestDistance) {
lowestDistance = d;
targetId = i;
}
}
// We have two occurences of 'closestId' - we only keep the closest instance!
const di = distances[i]
const dj = distances[j]
if (di < dj) {
closestIds[j] = undefined
// This candidates role is done, it can be removed from the distance matrix
distances.delete(candidate)
if (targetId !== undefined) {
// At this point, we have our target coordinate index: targetId!
// Lets map it...
closestIds[targetId] = candidate
// To indicate that this targetCoordinate is taken, we remove them from the distances matrix
distances.forEach(dists => {
dists[targetId] = undefined
})
} else {
closestIds[i] = undefined
// Seems like all the targetCoordinates have found a source point
unusedIds.push(candidate)
}
}
}
} while (candidate !== undefined)
const osmWay = <OsmWay>parsed[parsed.length - 1]
if (osmWay.type !== "way") {
throw "WEIRD: expected an OSM-way as last element here!"
// If there are still unused values in 'distances', they are definitively unused
distances.forEach((_, nodeId) => {
unusedIds.push(nodeId)
})
{
// Some extra data is included for rendering
const osmWay = <OsmWay>parsed[parsed.length - 1]
if (osmWay.type !== "way") {
throw "WEIRD: expected an OSM-way as last element here!"
}
const allNodesById = new Map<number, OsmNode>()
for (const node of allNodes) {
allNodesById.set(node.id, <OsmNode>node)
}
return {closestIds, allNodesById, osmWay, detachedNodeIds: unusedIds};
}
return {closestIds, allNodesById, osmWay};
}

View file

@ -384,8 +384,8 @@ export class Changes {
states.set(o.type + "/" + o.id, "unchanged")
}
let changed = false;
for (const change of changes) {
let changed = false;
const id = change.type + "/" + change.id
if (!objects.has(id)) {
// The object hasn't been seen before, so it doesn't exist yet and is newly created by its very definition
@ -493,7 +493,7 @@ export class Changes {
}
if (changed && state === "unchanged") {
if (changed && states.get(id) === "unchanged") {
states.set(id, "modified")
}
}
@ -520,6 +520,7 @@ export class Changes {
})
console.debug("Calculated the pending changes: ", result.newObjects.length,"new; ", result.modifiedObjects.length,"modified;",result.deletedObjects,"deleted")
return result
}
}

View file

@ -7,7 +7,6 @@ import Title from "../UI/Base/Title";
import {FixedUiElement} from "../UI/Base/FixedUiElement";
import LayerConfig from "../Models/ThemeConfig/LayerConfig";
import {CountryCoder} from "latlon2country"
import ScriptUtils from "../scripts/ScriptUtils";
export class SimpleMetaTagger {
@ -409,7 +408,21 @@ export default class SimpleMetaTaggers {
feature.properties["_loaded:datetime"] = datetime(freshness);
return true;
}
);
public static geometryType = new SimpleMetaTagger(
{
keys:["_geometry:type"],
doc: "Adds the geometry type as property. This is identical to the GoeJson geometry type and is one of `Point`,`LineString`, `Polygon` and exceptionally `MultiPolygon` or `MultiLineString`",
},
(feature, _) => {
const changed = feature.properties["_geometry:type"] === feature.geometry.type;
feature.properties["_geometry:type"] = feature.geometry.type;
return changed
}
)
public static metatags: SimpleMetaTagger[] = [
SimpleMetaTaggers.latlon,
SimpleMetaTaggers.layerInfo,
@ -421,7 +434,8 @@ export default class SimpleMetaTaggers {
SimpleMetaTaggers.directionSimplified,
SimpleMetaTaggers.currentTime,
SimpleMetaTaggers.objectMetaInfo,
SimpleMetaTaggers.noBothButLeftRight
SimpleMetaTaggers.noBothButLeftRight,
SimpleMetaTaggers.geometryType
];

View file

@ -59,7 +59,7 @@ export class And extends TagsFilter {
}
asHumanString(linkToWiki: boolean, shorten: boolean, properties) {
return this.and.map(t => t.asHumanString(linkToWiki, shorten, properties)).join("&");
return this.and.map(t => t.asHumanString(linkToWiki, shorten, properties)).filter(x => x !== "").join("&");
}
isUsableAsAnswer(): boolean {

View file

@ -41,12 +41,17 @@ export class Tag extends TagsFilter {
return [`["${this.key}"="${this.value}"]`];
}
asHumanString(linkToWiki?: boolean, shorten?: boolean) {
asHumanString(linkToWiki?: boolean, shorten?: boolean, currentProperties?: any) {
let v = this.value;
if (shorten) {
v = Utils.EllipsesAfter(v, 25);
}
if(v === "" || v === undefined){
// This tag will be removed if in the properties, so we indicate this with special rendering
if(currentProperties !== undefined && (currentProperties[this.key] ?? "") === ""){
// This tag is not present in the current properties, so this tag doesn't change anything
return ""
}
return "<span class='line-through'>"+this.key+"</span>"
}
if (linkToWiki) {

View file

@ -281,10 +281,12 @@ export class UIEventSource<T> {
* @param f: The transforming function
* @param extraSources: also trigger the update if one of these sources change
* @param g: a 'backfunction to let the sync run in two directions. (data of the new UIEVEntSource, currentData) => newData
* @param allowUnregister: if set, the update will be halted if no listeners are registered
*/
public map<J>(f: ((t: T) => J),
extraSources: UIEventSource<any>[] = [],
g: ((j: J, t: T) => T) = undefined): UIEventSource<J> {
g: ((j: J, t: T) => T) = undefined,
allowUnregister = false): UIEventSource<J> {
const self = this;
const stack = new Error().stack.split("\n");
@ -297,6 +299,7 @@ export class UIEventSource<T> {
const update = function () {
newSource.setData(f(self.data));
return allowUnregister && newSource._callbacks.length === 0
}
this.addCallback(update);